Micron Document
🎖️GitЯра🎖️

Commit 520fa717a938fd84682403b41f57d9f7e1ef49ef


Parents : 56332f4
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-04-10T15:54:09-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-04-10T20:54:09Z

refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)

Changes

74 files changed, 3623 insertions(+), 2328 deletions(-)


Diff

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
index a5069fb590..21c2d4fdea 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
@@ -23,32 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
+/** OSMDroid implementation of [MapViewProvider]. */
@Single
class FdroidMapViewProvider : MapViewProvider {
@Composable
- override fun MapView(
- modifier: Modifier,
- viewModel: Any,
- navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int?,
- nodeTracks: List<Any>?,
- tracerouteOverlay: Any?,
- tracerouteNodePositions: Map<Int, Any>,
- onTracerouteMappableCountChanged: (Int, Int) -> Unit,
- waypointId: Int?,
- ) {
+ override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
- @Suppress("UNCHECKED_CAST")
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
- focusedNodeNum = focusedNodeNum,
- nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
- tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
- onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index 5a59b53419..54935b4221 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -17,10 +17,8 @@
package org.meshtastic.app.map
import android.Manifest
-import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -38,9 +36,11 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -49,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -56,8 +57,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -79,6 +78,7 @@ import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.component.MapButton
+import org.meshtastic.app.map.component.MapControlsOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
@@ -96,6 +96,7 @@ import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
@@ -105,7 +106,6 @@ import org.meshtastic.core.resources.map_clear_tiles
import org.meshtastic.core.resources.map_download_complete
import org.meshtastic.core.resources.map_download_errors
import org.meshtastic.core.resources.map_download_region
-import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_node_popup_details
import org.meshtastic.core.resources.map_offline_manager
import org.meshtastic.core.resources.map_purge_fail
@@ -114,10 +114,8 @@ import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites
-import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
-import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.resources.waypoint_delete
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem
@@ -126,18 +124,13 @@ import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Lens
-import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.MyLocation
import org.meshtastic.core.ui.icon.PinDrop
-import org.meshtastic.core.ui.icon.Tune
-import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
-import org.meshtastic.feature.map.model.TracerouteOverlay
-import org.meshtastic.feature.map.tracerouteNodeSelection
-import org.meshtastic.proto.Position
+import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
@@ -156,38 +149,23 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
-import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File
-import kotlin.math.abs
-import kotlin.math.asin
-import kotlin.math.atan2
-import kotlin.math.cos
-import kotlin.math.sin
+import kotlin.math.roundToInt
private fun MapView.updateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
- trackMarkers: List<Marker>,
- trackPolylines: List<Polyline>,
nodeClusterer: RadiusMarkerClusterer,
) {
- Logger.d {
- "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks"
- }
-
- val trackOverlayIds = (trackMarkers + trackPolylines).toSet()
+ Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" }
overlays.removeAll { overlay ->
- overlay is MarkerWithLabel ||
- (overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) ||
- (overlay is Polyline && overlay !in trackOverlayIds)
+ overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items)
}
overlays.addAll(waypointMarkers)
- overlays.addAll(trackPolylines)
- overlays.addAll(trackMarkers)
nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers)
@@ -225,17 +203,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
-@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod")
+@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun MapView(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int? = null,
- nodeTracks: List<Position>? = null,
- tracerouteOverlay: TracerouteOverlay? = null,
- tracerouteNodePositions: Map<Int, Position> = emptyMap(),
- onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) {
var mapFilterExpanded by remember { mutableStateOf(false) }
@@ -334,6 +307,16 @@ fun MapView(
}
}
+ // Keep screen on while location tracking is active
+ LaunchedEffect(myLocationOverlay) {
+ val activity = context as? android.app.Activity ?: return@LaunchedEffect
+ if (myLocationOverlay != null) {
+ activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ } else {
+ activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ }
+
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
@@ -349,77 +332,21 @@ fun MapView(
}
}
- val tracerouteSelection =
- remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
- mapViewModel.tracerouteNodeSelection(
- tracerouteOverlay = tracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions,
- nodes = nodes,
- )
- }
- val overlayNodeNums = tracerouteSelection.overlayNodeNums
- val nodeLookup = tracerouteSelection.nodeLookup
- val nodesForMarkers = tracerouteSelection.nodesForMarkers
- val tracerouteForwardPoints =
- remember(tracerouteOverlay, nodeLookup) {
- tracerouteOverlay?.forwardRoute?.mapNotNull {
- nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
- } ?: emptyList()
- }
- val tracerouteReturnPoints =
- remember(tracerouteOverlay, nodeLookup) {
- tracerouteOverlay?.returnRoute?.mapNotNull {
- nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
- } ?: emptyList()
- }
- LaunchedEffect(tracerouteOverlay, nodesForMarkers) {
- if (tracerouteOverlay != null) {
- onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size)
- }
- }
- val tracerouteHeadingReferencePoints =
- remember(tracerouteForwardPoints, tracerouteReturnPoints) {
- when {
- tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
- tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
- else -> emptyList()
- }
- }
- val tracerouteForwardOffsetPoints =
- remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(
- points = tracerouteForwardPoints,
- offsetMeters = TRACEROUTE_OFFSET_METERS,
- headingReferencePoints = tracerouteHeadingReferencePoints,
- sideMultiplier = 1.0,
- )
- }
- val tracerouteReturnOffsetPoints =
- remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(
- points = tracerouteReturnPoints,
- offsetMeters = TRACEROUTE_OFFSET_METERS,
- headingReferencePoints = tracerouteHeadingReferencePoints,
- sideMultiplier = -1.0,
- )
- }
- val traceroutePolylines = remember { mutableStateListOf<Polyline>() }
- var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
-
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = mapViewModel.ourNodeInfo.value
- val displayUnits =
- mapViewModel.config.display?.units ?: org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.METRIC
+ val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
+ if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
+ return@mapNotNull null
+ }
if (
- mapFilterStateValue.onlyFavorites &&
- !node.isFavorite &&
- !overlayNodeNums.contains(node.num) &&
- !node.equals(ourNode)
+ mapFilterStateValue.lastHeardFilter.seconds != 0L &&
+ (nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds &&
+ node.num != ourNode?.num
) {
return@mapNotNull null
}
@@ -580,53 +507,6 @@ fun MapView(
invalidate()
}
- fun MapView.updateTracerouteOverlay(forwardPoints: List<GeoPoint>, returnPoints: List<GeoPoint>) {
- overlays.removeAll(traceroutePolylines)
- traceroutePolylines.clear()
-
- fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
- setPoints(points)
- outlinePaint.apply {
- this.color = color
- this.strokeWidth = strokeWidth
- strokeCap = Paint.Cap.ROUND
- strokeJoin = Paint.Join.ROUND
- style = Paint.Style.STROKE
- }
- }
-
- forwardPoints
- .takeIf { it.size >= 2 }
- ?.let { points ->
- traceroutePolylines.add(
- buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }),
- )
- }
- returnPoints
- .takeIf { it.size >= 2 }
- ?.let { points ->
- traceroutePolylines.add(
- buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }),
- )
- }
- overlays.addAll(traceroutePolylines)
- invalidate()
- }
-
- LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
- if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
- val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
- if (allPoints.isNotEmpty()) {
- if (allPoints.size == 1) {
- map.controller.setCenter(allPoints.first())
- map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
- } else {
- map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true)
- }
- hasCenteredTraceroute = true
- }
- }
-
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
val zoomFactor = 1.3
@@ -689,51 +569,6 @@ fun MapView(
}
}
- fun MapView.onTracksChanged(nodeTracks: List<Position>?, focusedNodeNum: Int?): Pair<List<Marker>, List<Polyline>> {
- if (nodeTracks == null || focusedNodeNum == null) return emptyList<Marker>() to emptyList<Polyline>()
-
- val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
- val timeFilteredPositions =
- nodeTracks.filter {
- lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
- }
- val sortedPositions = timeFilteredPositions.sortedBy { it.time }
-
- val focusedNode = nodes.find { it.num == focusedNodeNum } ?: return emptyList<Marker>() to emptyList<Polyline>()
- val color = focusedNode.colors.second
-
- val trackPolylines = mutableListOf<Polyline>()
- if (sortedPositions.size > 1) {
- val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
- segments.forEachIndexed { index, segmentPoints ->
- val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
- val polyline =
- Polyline().apply {
- setPoints(
- segmentPoints.map { GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) },
- )
- outlinePaint.color = Color(color).copy(alpha = alpha).toArgb()
- outlinePaint.strokeWidth = 8f
- }
- trackPolylines.add(polyline)
- }
- }
-
- val trackMarkers =
- sortedPositions.mapIndexedNotNull { index, position ->
- if (index == sortedPositions.lastIndex) return@mapIndexedNotNull null
-
- Marker(this).apply {
- this.position = GeoPoint((position.latitude_i ?: 0) * 1e-7, (position.longitude_i ?: 0) * 1e-7)
- icon = AppCompatResources.getDrawable(context, R.drawable.ic_map_location_dot)
- setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
- title = getString(Res.string.position)
- snippet = formatAgo(position.time)
- }
- }
- return trackMarkers to trackPolylines
- }
-
Scaffold(
modifier = modifier,
floatingActionButton = {
@@ -750,14 +585,10 @@ fun MapView(
},
modifier = Modifier.fillMaxSize(),
update = { mapView ->
- mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
- val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum)
with(mapView) {
updateMarkers(
- onNodesChanged(nodesForMarkers),
+ onNodesChanged(nodes),
onWaypointChanged(waypoints.values, selectedWaypointId),
- trackMarkers,
- trackPolylines,
nodeClusterer,
)
}
@@ -776,122 +607,34 @@ fun MapView(
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
- @Suppress("MagicNumber")
- Column(
- modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- MapButton(
- onClick = { showMapStyleDialog = true },
- icon = MeshtasticIcons.Layers,
- contentDescription = Res.string.map_style_selection,
- )
- Box(modifier = Modifier) {
- MapButton(
- onClick = { mapFilterExpanded = true },
- icon = MeshtasticIcons.Tune,
- contentDescription = stringResource(Res.string.map_filter),
- )
- DropdownMenu(
+ MapControlsOverlay(
+ modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
+ onToggleFilterMenu = { mapFilterExpanded = true },
+ filterDropdownContent = {
+ FdroidMainMapFilterDropdown(
expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false },
- modifier = Modifier.background(MaterialTheme.colorScheme.surface),
- ) {
- DropdownMenuItem(
- text = {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = MeshtasticIcons.Favorite,
- contentDescription = null,
- modifier = Modifier.padding(end = 8.dp),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = stringResource(Res.string.only_favorites),
- modifier = Modifier.weight(1f),
- )
- Checkbox(
- checked = mapFilterState.onlyFavorites,
- onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
- modifier = Modifier.padding(start = 8.dp),
- )
- }
- },
- onClick = { mapViewModel.toggleOnlyFavorites() },
- )
- DropdownMenuItem(
- text = {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = MeshtasticIcons.PinDrop,
- contentDescription = null,
- modifier = Modifier.padding(end = 8.dp),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = stringResource(Res.string.show_waypoints),
- modifier = Modifier.weight(1f),
- )
- Checkbox(
- checked = mapFilterState.showWaypoints,
- onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
- modifier = Modifier.padding(start = 8.dp),
- )
- }
- },
- onClick = { mapViewModel.toggleShowWaypointsOnMap() },
- )
- DropdownMenuItem(
- text = {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = MeshtasticIcons.Lens,
- contentDescription = null,
- modifier = Modifier.padding(end = 8.dp),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = stringResource(Res.string.show_precision_circle),
- modifier = Modifier.weight(1f),
- )
- @Suppress("MagicNumber")
- Checkbox(
- checked = mapFilterState.showPrecisionCircle,
- onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
- modifier = Modifier.padding(start = 8.dp),
- )
- }
- },
- onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
- )
- }
- }
- MapButton(
- icon =
- if (myLocationOverlay == null) {
- MeshtasticIcons.MyLocation
- } else {
- MeshtasticIcons.LocationDisabled
- },
- contentDescription = stringResource(Res.string.toggle_my_position),
- ) {
+ mapFilterState = mapFilterState,
+ mapViewModel = mapViewModel,
+ )
+ },
+ mapTypeContent = {
+ MapButton(
+ icon = MeshtasticIcons.Layers,
+ contentDescription = stringResource(Res.string.map_style_selection),
+ onClick = { showMapStyleDialog = true },
+ )
+ },
+ isLocationTrackingEnabled = myLocationOverlay != null,
+ onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation()
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
- }
- }
+ },
+ )
}
}
}
@@ -970,6 +713,103 @@ fun MapView(
}
}
+/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */
+@Composable
+private fun FdroidMainMapFilterDropdown(
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ mapFilterState: MapFilterState,
+ mapViewModel: MapViewModel,
+) {
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = onDismissRequest,
+ modifier = Modifier.background(MaterialTheme.colorScheme.surface),
+ ) {
+ DropdownMenuItem(
+ text = {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = MeshtasticIcons.Favorite,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
+ Checkbox(
+ checked = mapFilterState.onlyFavorites,
+ onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ },
+ onClick = { mapViewModel.toggleOnlyFavorites() },
+ )
+ DropdownMenuItem(
+ text = {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = MeshtasticIcons.PinDrop,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
+ Checkbox(
+ checked = mapFilterState.showWaypoints,
+ onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ },
+ onClick = { mapViewModel.toggleShowWaypointsOnMap() },
+ )
+ DropdownMenuItem(
+ text = {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = MeshtasticIcons.Lens,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
+ Checkbox(
+ checked = mapFilterState.showPrecisionCircle,
+ onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ },
+ onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
+ )
+ HorizontalDivider()
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ val filterOptions = LastHeardFilter.entries
+ val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
+ var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
+ Text(
+ text =
+ stringResource(
+ Res.string.last_heard_filter_label,
+ stringResource(mapFilterState.lastHeardFilter.label),
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Slider(
+ value = sliderPosition,
+ onValueChange = { sliderPosition = it },
+ onValueChangeFinished = {
+ val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
+ mapViewModel.setLastHeardFilter(filterOptions[newIndex])
+ },
+ valueRange = 0f..(filterOptions.size - 1).toFloat(),
+ steps = filterOptions.size - 2,
+ )
+ }
+ }
+}
+
@Composable
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
val selected = remember { mutableStateOf(selectedMapStyle) }
@@ -1125,57 +965,4 @@ private fun MapsDialog(
}
}
-private const val EARTH_RADIUS_METERS = 6_371_000.0
-private const val TRACEROUTE_OFFSET_METERS = 100.0
-private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
-private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
private const val WAYPOINT_ZOOM = 15.0
-
-@Suppress("MagicNumber")
-private fun Double.toRad(): Double = this * Math.PI / 180.0
-
-private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
- val lat1 = from.latitude.toRad()
- val lat2 = to.latitude.toRad()
- val dLon = (to.longitude - from.longitude).toRad()
- return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
-}
-
-private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
- val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
- val lat1 = latitude.toRad()
- val lon1 = longitude.toRad()
- val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
- val lon2 =
- lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
- return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2))
-}
-
-private fun offsetPolyline(
- points: List<GeoPoint>,
- offsetMeters: Double,
- headingReferencePoints: List<GeoPoint> = points,
- sideMultiplier: Double = 1.0,
-): List<GeoPoint> {
- val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
- if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
-
- val headings =
- headingPoints.mapIndexed { index, _ ->
- when (index) {
- 0 -> bearingRad(headingPoints[0], headingPoints[1])
- headingPoints.lastIndex ->
- bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
-
- else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
- }
- }
-
- return points.mapIndexed { index, point ->
- val heading = headings[index.coerceIn(0, headings.lastIndex)]
-
- @Suppress("MagicNumber")
- val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
- point.offsetPoint(perpendicularHeading, abs(offsetMeters))
- }
-}

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
index d6e84d19b0..c16d871630 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
@@ -16,9 +16,6 @@
*/
package org.meshtastic.app.map
-import android.annotation.SuppressLint
-import android.content.Context
-import android.os.PowerManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -32,7 +29,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
-import co.touchlab.kermit.Logger
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
@@ -41,29 +37,6 @@ import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
-@SuppressLint("WakelockTimeout")
-private fun PowerManager.WakeLock.safeAcquire() {
- if (!isHeld) {
- try {
- acquire()
- } catch (e: SecurityException) {
- Logger.e { "WakeLock permission exception: ${e.message}" }
- } catch (e: IllegalStateException) {
- Logger.e { "WakeLock acquire() exception: ${e.message}" }
- }
- }
-}
-
-private fun PowerManager.WakeLock.safeRelease() {
- if (isHeld) {
- try {
- release()
- } catch (e: IllegalStateException) {
- Logger.e { "WakeLock release() exception: ${e.message}" }
- }
- }
-}
-
private const val MIN_ZOOM_LEVEL = 1.5
private const val MAX_ZOOM_LEVEL = 20.0
private const val DEFAULT_ZOOM_LEVEL = 15.0
@@ -136,22 +109,13 @@ internal fun rememberMapViewWithLifecycle(
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
- val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
-
- @Suppress("DEPRECATION")
- val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
-
- wakeLock.safeAcquire()
-
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
- wakeLock.safeRelease()
mapView.onPause()
}
Lifecycle.Event.ON_RESUME -> {
- wakeLock.safeAcquire()
mapView.onResume()
}
@@ -166,10 +130,7 @@ internal fun rememberMapViewWithLifecycle(
lifecycle.addObserver(observer)
- onDispose {
- lifecycle.removeObserver(observer)
- wakeLock.safeRelease()
- }
+ onDispose { lifecycle.removeObserver(observer) }
}
return mapView
}

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt
deleted file mode 100644
index 22eac8c02e..0000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-package org.meshtastic.app.map.component
-
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.tooling.preview.PreviewLightDark
-import androidx.compose.ui.unit.dp
-import org.jetbrains.compose.resources.StringResource
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.map_style_selection
-import org.meshtastic.core.ui.icon.Layers
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.theme.AppTheme
-
-@Composable
-fun MapButton(
- icon: ImageVector,
- contentDescription: StringResource,
- modifier: Modifier = Modifier,
- onClick: () -> Unit = {},
-) {
- MapButton(
- icon = icon,
- contentDescription = stringResource(contentDescription),
- modifier = modifier,
- onClick = onClick,
- )
-}
-
-@Composable
-fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
- FloatingActionButton(onClick = onClick, modifier = modifier) {
- Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
- }
-}
-
-@PreviewLightDark
-@Composable
-private fun MapButtonPreview() {
- AppTheme { MapButton(icon = MeshtasticIcons.Layers, contentDescription = Res.string.map_style_selection) }
-}

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
index 668f174132..b7795180f1 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
@@ -17,48 +17,38 @@
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.meshtastic.app.map.addCopyright
-import org.meshtastic.app.map.addPolyline
-import org.meshtastic.app.map.addPositionMarkers
-import org.meshtastic.app.map.addScaleBarOverlay
-import org.meshtastic.app.map.model.CustomTileSource
-import org.meshtastic.app.map.rememberMapViewWithLifecycle
+import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
-import org.osmdroid.util.BoundingBox
-import org.osmdroid.util.GeoPoint
-
-private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
- val density = LocalDensity.current
- val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
- val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
- val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
- val mapView =
- rememberMapViewWithLifecycle(
- applicationId = nodeMapViewModel.applicationId,
- box = cameraView,
- tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId),
- )
+ val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
+ val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
- AndroidView(
- modifier = Modifier.fillMaxSize(),
- factory = { mapView },
- update = { map ->
- map.overlays.clear()
- map.addCopyright()
- map.addScaleBarOverlay(density)
-
- map.addPolyline(density, geoPoints) {}
- map.addPositionMarkers(positionLogs) {}
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = node?.user?.long_name ?: "",
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onNavigateUp,
+ actions = {},
+ onClickChip = {},
+ )
},
- )
+ ) { paddingValues ->
+ NodeTrackOsmMap(
+ positions = positions,
+ applicationId = nodeMapViewModel.applicationId,
+ mapStyleId = nodeMapViewModel.mapStyleId,
+ modifier = Modifier.fillMaxSize().padding(paddingValues),
+ )
+ }
}

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
new file mode 100644
index 0000000000..0178a498ed
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.node
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.feature.map.node.NodeMapViewModel
+import org.meshtastic.proto.Position
+
+/**
+ * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain
+ * [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation
+ * ([NodeTrackOsmMap]).
+ */
+@Composable
+fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
+ val vm = koinViewModel<NodeMapViewModel>()
+ vm.setDestNum(destNum)
+ NodeTrackOsmMap(
+ positions = positions,
+ applicationId = vm.applicationId,
+ mapStyleId = vm.mapStyleId,
+ modifier = modifier,
+ )
+}

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
new file mode 100644
index 0000000000..64d207a6ea
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.node
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.app.map.MapViewModel
+import org.meshtastic.app.map.addCopyright
+import org.meshtastic.app.map.addPolyline
+import org.meshtastic.app.map.addPositionMarkers
+import org.meshtastic.app.map.addScaleBarOverlay
+import org.meshtastic.app.map.component.MapControlsOverlay
+import org.meshtastic.app.map.model.CustomTileSource
+import org.meshtastic.app.map.rememberMapViewWithLifecycle
+import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.core.model.util.GeoConstants.DEG_D
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.last_heard_filter_label
+import org.meshtastic.feature.map.LastHeardFilter
+import org.meshtastic.proto.Position
+import org.osmdroid.util.BoundingBox
+import org.osmdroid.util.GeoPoint
+import kotlin.math.roundToInt
+
+/**
+ * A focused OSMDroid map composable that renders **only** a node's position track — a dashed polyline with directional
+ * markers for each historical position.
+ *
+ * Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter]
+ * from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a
+ * minimal [MapControlsOverlay][org.meshtastic.app.map.component.MapControlsOverlay] with a track time filter slider so
+ * users can adjust the time range directly from the map.
+ *
+ * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or
+ * location tracking. It is designed to be embedded inside the position-log adaptive layout.
+ */
+@Composable
+fun NodeTrackOsmMap(
+ positions: List<Position>,
+ applicationId: String,
+ mapStyleId: Int,
+ modifier: Modifier = Modifier,
+ mapViewModel: MapViewModel = koinViewModel(),
+) {
+ val density = LocalDensity.current
+ val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
+ val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
+
+ val filteredPositions =
+ remember(positions, lastHeardTrackFilter) {
+ positions.filter {
+ lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
+ }
+ }
+
+ val geoPoints =
+ remember(filteredPositions) {
+ filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
+ }
+ val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) }
+ val mapView =
+ rememberMapViewWithLifecycle(
+ applicationId = applicationId,
+ box = cameraView,
+ tileSource = CustomTileSource.getTileSource(mapStyleId),
+ )
+
+ var filterMenuExpanded by remember { mutableStateOf(false) }
+
+ Box(modifier = modifier) {
+ AndroidView(
+ modifier = Modifier.matchParentSize(),
+ factory = { mapView },
+ update = { map ->
+ map.overlays.clear()
+ map.addCopyright()
+ map.addScaleBarOverlay(density)
+ map.addPolyline(density, geoPoints) {}
+ map.addPositionMarkers(filteredPositions) {}
+ },
+ )
+
+ // Track filter controls overlay
+ MapControlsOverlay(
+ modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
+ onToggleFilterMenu = { filterMenuExpanded = true },
+ filterDropdownContent = {
+ DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ val filterOptions = LastHeardFilter.entries
+ val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter)
+ var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
+
+ Text(
+ text =
+ stringResource(
+ Res.string.last_heard_filter_label,
+ stringResource(lastHeardTrackFilter.label),
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Slider(
+ value = sliderPosition,
+ onValueChange = { sliderPosition = it },
+ onValueChangeFinished = {
+ val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
+ mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
+ },
+ valueRange = 0f..(filterOptions.size - 1).toFloat(),
+ steps = filterOptions.size - 2,
+ )
+ }
+ }
+ },
+ )
+ }
+}

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
new file mode 100644
index 0000000000..fcf1d47e97
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.traceroute
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.proto.Position
+
+/**
+ * Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation
+ * ([TracerouteOsmMap]).
+ */
+@Composable
+fun TracerouteMap(
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map<Int, Position>,
+ onMappableCountChanged: (shown: Int, total: Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ TracerouteOsmMap(
+ tracerouteOverlay = tracerouteOverlay,
+ tracerouteNodePositions = tracerouteNodePositions,
+ onMappableCountChanged = onMappableCountChanged,
+ modifier = modifier,
+ )
+}

diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt
new file mode 100644
index 0000000000..55b49154a0
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt
@@ -0,0 +1,288 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+@file:Suppress("MagicNumber")
+
+package org.meshtastic.app.map.traceroute
+
+import android.graphics.Paint
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.app.R
+import org.meshtastic.app.map.MapViewModel
+import org.meshtastic.app.map.addCopyright
+import org.meshtastic.app.map.addScaleBarOverlay
+import org.meshtastic.app.map.model.CustomTileSource
+import org.meshtastic.app.map.model.MarkerWithLabel
+import org.meshtastic.app.map.rememberMapViewWithLifecycle
+import org.meshtastic.app.map.zoomIn
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS
+import org.meshtastic.core.ui.theme.TracerouteColors
+import org.meshtastic.core.ui.util.formatAgo
+import org.meshtastic.feature.map.tracerouteNodeSelection
+import org.meshtastic.proto.Position
+import org.osmdroid.util.BoundingBox
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.overlay.Marker
+import org.osmdroid.views.overlay.Polyline
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+
+private const val TRACEROUTE_OFFSET_METERS = 100.0
+private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
+private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
+
+/**
+ * A focused OSMDroid map composable that renders **only** traceroute visualization — node markers for each hop and
+ * forward/return offset polylines with auto-centering camera.
+ *
+ * Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any
+ * map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold.
+ */
+@Composable
+fun TracerouteOsmMap(
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map<Int, Position>,
+ onMappableCountChanged: (shown: Int, total: Int) -> Unit,
+ modifier: Modifier = Modifier,
+ mapViewModel: MapViewModel = koinViewModel(),
+) {
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
+ val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
+
+ // Resolve which nodes to display for the traceroute
+ val tracerouteSelection =
+ remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
+ mapViewModel.tracerouteNodeSelection(
+ tracerouteOverlay = tracerouteOverlay,
+ tracerouteNodePositions = tracerouteNodePositions,
+ nodes = nodes,
+ )
+ }
+ val displayNodes = tracerouteSelection.nodesForMarkers
+ val nodeLookup = tracerouteSelection.nodeLookup
+
+ // Report mappable count
+ LaunchedEffect(tracerouteOverlay, displayNodes) {
+ if (tracerouteOverlay != null) {
+ onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
+ }
+ }
+
+ // Compute polyline GeoPoints from node positions
+ val forwardPoints =
+ remember(tracerouteOverlay, nodeLookup) {
+ tracerouteOverlay?.forwardRoute?.mapNotNull {
+ nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
+ } ?: emptyList()
+ }
+ val returnPoints =
+ remember(tracerouteOverlay, nodeLookup) {
+ tracerouteOverlay?.returnRoute?.mapNotNull {
+ nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
+ } ?: emptyList()
+ }
+
+ // Compute offset polylines for visual separation
+ val headingReferencePoints =
+ remember(forwardPoints, returnPoints) {
+ when {
+ forwardPoints.size >= 2 -> forwardPoints
+ returnPoints.size >= 2 -> returnPoints
+ else -> emptyList()
+ }
+ }
+ val forwardOffsetPoints =
+ remember(forwardPoints, headingReferencePoints) {
+ offsetPolyline(
+ points = forwardPoints,
+ offsetMeters = TRACEROUTE_OFFSET_METERS,
+ headingReferencePoints = headingReferencePoints,
+ sideMultiplier = 1.0,
+ )
+ }
+ val returnOffsetPoints =
+ remember(returnPoints, headingReferencePoints) {
+ offsetPolyline(
+ points = returnPoints,
+ offsetMeters = TRACEROUTE_OFFSET_METERS,
+ headingReferencePoints = headingReferencePoints,
+ sideMultiplier = -1.0,
+ )
+ }
+
+ // Camera auto-center
+ var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) }
+
+ // Build initial camera from all traceroute points
+ val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() }
+ val initialCameraView =
+ remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) }
+
+ val mapView =
+ rememberMapViewWithLifecycle(
+ applicationId = mapViewModel.applicationId,
+ box = initialCameraView ?: BoundingBox(),
+ tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId),
+ )
+
+ // Center camera on traceroute bounds
+ LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) {
+ if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect
+ if (allPoints.isNotEmpty()) {
+ if (allPoints.size == 1) {
+ mapView.controller.setCenter(allPoints.first())
+ mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
+ } else {
+ mapView.zoomToBoundingBox(
+ BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS),
+ true,
+ )
+ }
+ hasCentered = true
+ }
+ }
+
+ AndroidView(
+ modifier = modifier,
+ factory = { mapView.apply { setDestroyMode(false) } },
+ update = { map ->
+ map.overlays.clear()
+ map.addCopyright()
+ map.addScaleBarOverlay(density)
+
+ // Render traceroute polylines
+ buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) }
+
+ // Render simple node markers
+ displayNodes.forEach { node ->
+ val position = GeoPoint(node.latitude, node.longitude)
+ val marker =
+ MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}")
+ .apply {
+ id = node.user.id
+ title = node.user.long_name
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
+ this.position = position
+ icon = markerIcon
+ setNodeColors(node.colors)
+ }
+ map.overlays.add(marker)
+ }
+
+ map.invalidate()
+ },
+ )
+}
+
+private fun buildTraceroutePolylines(
+ forwardPoints: List<GeoPoint>,
+ returnPoints: List<GeoPoint>,
+ density: androidx.compose.ui.unit.Density,
+): List<Polyline> {
+ val polylines = mutableListOf<Polyline>()
+
+ fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
+ setPoints(points)
+ outlinePaint.apply {
+ this.color = color
+ this.strokeWidth = strokeWidth
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
+ style = Paint.Style.STROKE
+ }
+ }
+
+ forwardPoints
+ .takeIf { it.size >= 2 }
+ ?.let { points ->
+ polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }))
+ }
+ returnPoints
+ .takeIf { it.size >= 2 }
+ ?.let { points ->
+ polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }))
+ }
+ return polylines
+}
+
+// --- Haversine offset math for OSMDroid (no SphericalUtil available) ---
+
+private fun Double.toRad(): Double = this * PI / 180.0
+
+private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
+ val lat1 = from.latitude.toRad()
+ val lat2 = to.latitude.toRad()
+ val dLon = (to.longitude - from.longitude).toRad()
+ return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
+}
+
+private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
+ val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
+ val lat1 = latitude.toRad()
+ val lon1 = longitude.toRad()
+ val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
+ val lon2 =
+ lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
+ return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI)
+}
+
+private fun offsetPolyline(
+ points: List<GeoPoint>,
+ offsetMeters: Double,
+ headingReferencePoints: List<GeoPoint> = points,
+ sideMultiplier: Double = 1.0,
+): List<GeoPoint> {
+ val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
+ if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
+
+ val headings =
+ headingPoints.mapIndexed { index, _ ->
+ when (index) {
+ 0 -> bearingRad(headingPoints[0], headingPoints[1])
+ headingPoints.lastIndex ->
+ bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
+
+ else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
+ }
+ }
+
+ return points.mapIndexed { index, point ->
+ val heading = headings[index.coerceIn(0, headings.lastIndex)]
+ val perpendicularHeading = heading + (PI / 2 * sideMultiplier)
+ point.offsetPoint(perpendicularHeading, abs(offsetMeters))
+ }
+}

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
index c228297a39..940c4ab5a0 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
@@ -23,31 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
+/** Google Maps implementation of [MapViewProvider]. */
@Single
class GoogleMapViewProvider : MapViewProvider {
@Composable
- override fun MapView(
- modifier: Modifier,
- viewModel: Any,
- navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int?,
- nodeTracks: List<Any>?,
- tracerouteOverlay: Any?,
- tracerouteNodePositions: Map<Int, Any>,
- onTracerouteMappableCountChanged: (Int, Int) -> Unit,
- waypointId: Int?,
- ) {
+ override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
- focusedNodeNum = focusedNodeNum,
- nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
- tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
- onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 6330248aaa..530fc0c7b1 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -74,6 +74,7 @@ import com.google.android.gms.maps.model.JointType
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.maps.android.SphericalUtil
+import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapEffect
@@ -85,10 +86,13 @@ import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
+import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import com.google.maps.android.compose.widgets.ScaleBar
+import com.google.maps.android.data.Layer
import com.google.maps.android.data.geojson.GeoJsonLayer
import com.google.maps.android.data.kml.KmlLayer
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.json.JSONObject
@@ -97,13 +101,20 @@ import org.meshtastic.app.map.component.ClusterItemsListDialog
import org.meshtastic.app.map.component.CustomMapLayersSheet
import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
import org.meshtastic.app.map.component.EditWaypointDialog
+import org.meshtastic.app.map.component.MapButton
import org.meshtastic.app.map.component.MapControlsOverlay
+import org.meshtastic.app.map.component.MapFilterDropdown
+import org.meshtastic.app.map.component.MapTypeDropdown
import org.meshtastic.app.map.component.NodeClusterMarkers
+import org.meshtastic.app.map.component.NodeMapFilterDropdown
import org.meshtastic.app.map.component.WaypointMarkers
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.core.model.util.GeoConstants.DEG_D
+import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
@@ -113,19 +124,23 @@ import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
+import org.meshtastic.core.resources.manage_map_layers
+import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
import org.meshtastic.core.ui.component.NodeChip
+import org.meshtastic.core.ui.icon.Layers
+import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.TripOrigin
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime
+import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
-import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
@@ -133,9 +148,30 @@ import org.meshtastic.proto.Waypoint
import kotlin.math.abs
import kotlin.math.max
-private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
-private const val DEG_D = 1e-7
-private const val HEADING_DEG = 1e-5
+// region --- Map Mode ---
+
+/**
+ * Discriminated mode for [MapView] — replaces the original pile of nullable parameters with a type-safe sealed
+ * hierarchy. Each mode carries only the data it needs; the shared infrastructure (location tracking, tile providers,
+ * controls overlay) is available in every mode.
+ */
+sealed interface GoogleMapMode {
+ /** Standard map: node clusters, waypoints, custom layers, waypoint editing. */
+ data object Main : GoogleMapMode
+
+ /** Focused node position track: polyline + gradient markers for historical positions. */
+ data class NodeTrack(val focusedNode: Node?, val positions: List<Position>) : GoogleMapMode
+
+ /** Traceroute visualization: offset forward/return polylines + hop markers. */
+ data class Traceroute(
+ val overlay: TracerouteOverlay?,
+ val nodePositions: Map<Int, Position>,
+ val onMappableCountChanged: (shown: Int, total: Int) -> Unit,
+ ) : GoogleMapMode
+}
+
+// endregion
+
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
@@ -145,28 +181,22 @@ private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
fun MapView(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
- navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int? = null,
- nodeTracks: List<Position>? = null,
- tracerouteOverlay: TracerouteOverlay? = null,
- tracerouteNodePositions: Map<Int, Position> = emptyMap(),
- onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
+ navigateToNodeDetails: (Int) -> Unit = {},
+ mode: GoogleMapMode = GoogleMapMode.Main,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
- val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
- // Location permissions state
+ // --- Location permissions ---
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
- // Location tracking state
+ // --- Location tracking ---
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
var followPhoneBearing by remember { mutableStateOf(false) }
- // Effect to toggle location tracking after permission is granted
LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
isLocationTrackingEnabled = true
@@ -174,9 +204,10 @@ fun MapView(
}
}
+ // --- File picker for map layers (Main mode) ---
val filePickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == android.app.Activity.RESULT_OK) {
+ if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
val fileName = uri.getFileName(context)
mapViewModel.addMapLayer(uri, fileName)
@@ -184,6 +215,7 @@ fun MapView(
}
}
+ // --- UI state ---
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
@@ -195,16 +227,20 @@ fun MapView(
var mapTypeMenuExpanded by remember { mutableStateOf(false) }
var showCustomTileManagerSheet by remember { mutableStateOf(false) }
- val cameraPositionState = mapViewModel.cameraPositionState
+ // --- Camera ---
+ // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering.
+ val cameraPositionState =
+ if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState()
- // Save camera position when it stops moving
- LaunchedEffect(cameraPositionState.isMoving) {
- if (!cameraPositionState.isMoving) {
- mapViewModel.saveCameraPosition(cameraPositionState.position)
+ if (mode is GoogleMapMode.Main) {
+ LaunchedEffect(cameraPositionState.isMoving) {
+ if (!cameraPositionState.isMoving) {
+ mapViewModel.saveCameraPosition(cameraPositionState.position)
+ }
}
}
- // Location tracking functionality
+ // --- FusedLocation ---
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
val locationCallback = remember {
object : LocationCallback() {
@@ -243,14 +279,12 @@ fun MapView(
}
}
- // Start/stop location tracking based on state
LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) {
if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
.setMinUpdateIntervalMillis(2000L)
.build()
-
try {
@Suppress("MissingPermission")
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
@@ -267,20 +301,12 @@ fun MapView(
DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
+ // --- Node & waypoint data ---
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint }
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
- val tracerouteSelection =
- remember(tracerouteOverlay, tracerouteNodePositions, allNodes) {
- mapViewModel.tracerouteNodeSelection(
- tracerouteOverlay = tracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions,
- nodes = allNodes,
- )
- }
-
val filteredNodes =
allNodes
.filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
@@ -290,30 +316,7 @@ fun MapView(
node.num == ourNodeInfo?.num
}
- val displayNodes =
- if (tracerouteOverlay != null) {
- tracerouteSelection.nodesForMarkers
- } else {
- filteredNodes
- }
- LaunchedEffect(tracerouteOverlay, displayNodes) {
- if (tracerouteOverlay != null) {
- onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
- }
- }
-
val myNodeNum = mapViewModel.myNodeNum
- val nodeClusterItems =
- displayNodes.map { node ->
- val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
- NodeClusterItem(
- node = node,
- nodePosition = latLng,
- nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
- nodeSnippet = "${node.user.long_name}",
- myNodeNum = myNodeNum,
- )
- }
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
val theme by mapViewModel.theme.collectAsStateWithLifecycle()
val dark =
@@ -323,20 +326,69 @@ fun MapView(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
else -> isSystemInDarkTheme()
}
- val mapColorScheme =
- when (dark) {
- true -> ComposeMapColorScheme.DARK
- else -> ComposeMapColorScheme.LIGHT
+ val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
+
+ // --- Mode-specific data ---
+ // Node track: apply time filter
+ val sortedTrackPositions =
+ if (mode is GoogleMapMode.NodeTrack) {
+ val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
+ remember(mode.positions, lastHeardTrackFilter) {
+ mode.positions
+ .filter {
+ lastHeardTrackFilter == LastHeardFilter.Any ||
+ it.time > nowSeconds - lastHeardTrackFilter.seconds
+ }
+ .sortedBy { it.time }
+ }
+ } else {
+ emptyList()
+ }
+
+ // Traceroute: resolve node selection + polylines. Collected unconditionally per Compose rules
+ // (composable calls cannot be conditional), but only consumed in Traceroute mode. Uses all
+ // nodes, not just those with positions, so getNodeOrFallback can resolve metadata for hops
+ // whose positions come from snapshots.
+ val allNodesForTraceroute by mapViewModel.nodes.collectAsStateWithLifecycle(listOf())
+ val tracerouteSelection =
+ if (mode is GoogleMapMode.Traceroute) {
+ remember(mode.overlay, mode.nodePositions, allNodesForTraceroute) {
+ mapViewModel.tracerouteNodeSelection(
+ tracerouteOverlay = mode.overlay,
+ tracerouteNodePositions = mode.nodePositions,
+ nodes = allNodesForTraceroute,
+ )
+ }
+ } else {
+ null
}
- val tracerouteForwardPoints =
- remember(tracerouteOverlay, displayNodes) {
- val nodeLookup = displayNodes.associateBy { it.num }
- tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
+ val tracerouteDisplayNodes = tracerouteSelection?.nodesForMarkers ?: emptyList()
+
+ if (mode is GoogleMapMode.Traceroute) {
+ LaunchedEffect(mode.overlay, tracerouteDisplayNodes) {
+ if (mode.overlay != null) {
+ mode.onMappableCountChanged(tracerouteDisplayNodes.size, mode.overlay.relatedNodeNums.size)
+ }
}
- val tracerouteReturnPoints =
- remember(tracerouteOverlay, displayNodes) {
- val nodeLookup = displayNodes.associateBy { it.num }
- tracerouteOverlay?.returnRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
+ }
+
+ val tracerouteForwardPoints: List<LatLng> =
+ if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) {
+ val nodeLookup = tracerouteSelection.nodeLookup
+ remember(mode.overlay, nodeLookup) {
+ mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()
+ }
+ } else {
+ emptyList()
+ }
+ val tracerouteReturnPoints: List<LatLng> =
+ if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) {
+ val nodeLookup = tracerouteSelection.nodeLookup
+ remember(mode.overlay, nodeLookup) {
+ mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()
+ }
+ } else {
+ emptyList()
}
val tracerouteHeadingReferencePoints =
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
@@ -348,24 +400,64 @@ fun MapView(
}
val tracerouteForwardOffsetPoints =
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(
- points = tracerouteForwardPoints,
- offsetMeters = TRACEROUTE_OFFSET_METERS,
- headingReferencePoints = tracerouteHeadingReferencePoints,
- sideMultiplier = 1.0,
- )
+ offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0)
}
val tracerouteReturnOffsetPoints =
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(
- points = tracerouteReturnPoints,
- offsetMeters = TRACEROUTE_OFFSET_METERS,
- headingReferencePoints = tracerouteHeadingReferencePoints,
- sideMultiplier = -1.0,
- )
+ offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0)
+ }
+
+ // Auto-centering for NodeTrack / Traceroute modes
+ var hasCentered by remember(mode) { mutableStateOf(false) }
+
+ if (mode is GoogleMapMode.NodeTrack) {
+ LaunchedEffect(sortedTrackPositions, hasCentered) {
+ if (hasCentered || sortedTrackPositions.isEmpty()) return@LaunchedEffect
+ val points = sortedTrackPositions.map { it.toLatLng() }
+ val cameraUpdate =
+ if (points.size == 1) {
+ CameraUpdateFactory.newLatLngZoom(points.first(), max(cameraPositionState.position.zoom, 12f))
+ } else {
+ val bounds = LatLngBounds.builder()
+ points.forEach { bounds.include(it) }
+ CameraUpdateFactory.newLatLngBounds(bounds.build(), 80)
+ }
+ try {
+ cameraPositionState.animate(cameraUpdate)
+ hasCentered = true
+ } catch (e: IllegalStateException) {
+ Logger.d { "Error centering track map: ${e.message}" }
+ }
}
- var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
+ }
+ if (mode is GoogleMapMode.Traceroute) {
+ LaunchedEffect(mode.overlay, tracerouteForwardPoints, tracerouteReturnPoints) {
+ if (mode.overlay == null || hasCentered) return@LaunchedEffect
+ val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
+ if (allPoints.isNotEmpty()) {
+ val cameraUpdate =
+ if (allPoints.size == 1) {
+ CameraUpdateFactory.newLatLngZoom(
+ allPoints.first(),
+ max(cameraPositionState.position.zoom, 12f),
+ )
+ } else {
+ val bounds = LatLngBounds.builder()
+ allPoints.forEach { bounds.include(it) }
+ CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
+ }
+ try {
+ cameraPositionState.animate(cameraUpdate)
+ hasCentered = true
+ } catch (e: IllegalStateException) {
+ Logger.d { "Error centering traceroute overlay: ${e.message}" }
+ }
+ }
+ }
+ }
+
+ // --- Tile & layers state ---
var showLayersBottomSheet by remember { mutableStateOf(false) }
val onAddLayerClicked = {
@@ -388,45 +480,23 @@ fun MapView(
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
- val effectiveGoogleMapType =
- if (currentCustomTileProviderUrl != null) {
- MapType.NONE
- } else {
- selectedGoogleMapType
- }
+ val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
+ // --- Keep screen on while location tracking ---
LaunchedEffect(isLocationTrackingEnabled) {
val activity = context as? Activity ?: return@LaunchedEffect
val window = activity.window
-
if (isLocationTrackingEnabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
- LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
- if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
- val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
- if (allPoints.isNotEmpty()) {
- val cameraUpdate =
- if (allPoints.size == 1) {
- CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f))
- } else {
- val bounds = LatLngBounds.builder()
- allPoints.forEach { bounds.include(it) }
- CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
- }
- try {
- cameraPositionState.animate(cameraUpdate)
- hasCenteredTraceroute = true
- } catch (e: IllegalStateException) {
- Logger.d { "Error centering traceroute overlay: ${e.message}" }
- }
- }
- }
+
+ // --- Main UI ---
+ val isMainMode = mode is GoogleMapMode.Main
Box(modifier = modifier) {
GoogleMap(
@@ -436,12 +506,12 @@ fun MapView(
uiSettings =
MapUiSettings(
zoomControlsEnabled = true,
- mapToolbarEnabled = true,
+ mapToolbarEnabled = isMainMode,
compassEnabled = false,
myLocationButtonEnabled = false,
rotationGesturesEnabled = true,
scrollGesturesEnabled = true,
- tiltGesturesEnabled = true,
+ tiltGesturesEnabled = isMainMode,
zoomGesturesEnabled = true,
),
properties =
@@ -450,16 +520,16 @@ fun MapView(
isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted,
),
onMapLongClick = { latLng ->
- if (isConnected) {
- val newWaypoint =
+ if (isMainMode && isConnected) {
+ editingWaypoint =
Waypoint(
latitude_i = (latLng.latitude / DEG_D).toInt(),
longitude_i = (latLng.longitude / DEG_D).toInt(),
)
- editingWaypoint = newWaypoint
}
},
) {
+ // Custom tile overlay (all modes)
key(currentCustomTileProviderUrl) {
currentCustomTileProviderUrl?.let { url ->
val config =
@@ -472,180 +542,143 @@ fun MapView(
}
}
- if (tracerouteForwardPoints.size >= 2) {
- Polyline(
- points = tracerouteForwardOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.OutgoingRoute,
- width = 9f,
- zIndex = 3.0f,
- )
- }
- if (tracerouteReturnPoints.size >= 2) {
- Polyline(
- points = tracerouteReturnOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.ReturnRoute,
- width = 7f,
- zIndex = 2.5f,
- )
- }
+ when (mode) {
+ is GoogleMapMode.Main ->
+ MainMapContent(
+ nodeClusterItems =
+ filteredNodes.map { node ->
+ val latLng =
+ LatLng(
+ (node.position.latitude_i ?: 0) * DEG_D,
+ (node.position.longitude_i ?: 0) * DEG_D,
+ )
+ NodeClusterItem(
+ node = node,
+ nodePosition = latLng,
+ nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
+ nodeSnippet = "${node.user.long_name}",
+ myNodeNum = myNodeNum,
+ )
+ },
+ mapFilterState = mapFilterState,
+ navigateToNodeDetails = navigateToNodeDetails,
+ displayableWaypoints = displayableWaypoints,
+ myNodeNum = myNodeNum,
+ isConnected = isConnected,
+ onEditWaypointRequest = { editingWaypoint = it },
+ selectedWaypointId = selectedWaypointId,
+ mapLayers = mapLayers,
+ mapViewModel = mapViewModel,
+ cameraPositionState = cameraPositionState,
+ coroutineScope = coroutineScope,
+ onShowClusterItemsDialog = { showClusterItemsDialog = it },
+ )
- if (nodeTracks != null && focusedNodeNum != null) {
- val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
- val timeFilteredPositions =
- nodeTracks.filter {
- lastHeardTrackFilter == LastHeardFilter.Any ||
- it.time > nowSeconds - lastHeardTrackFilter.seconds
+ is GoogleMapMode.NodeTrack -> {
+ val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
+ if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) {
+ NodeTrackOverlay(
+ focusedNode = mode.focusedNode,
+ sortedPositions = sortedTrackPositions,
+ displayUnits = displayUnits,
+ myNodeNum = myNodeNum,
+ )
}
- val sortedPositions = timeFilteredPositions.sortedBy { it.time }
- allNodes
- .find { it.num == focusedNodeNum }
- ?.let { focusedNode ->
- sortedPositions.forEachIndexed { index, position ->
- key(position.time) {
- val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
- val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
- val color = Color(focusedNode.colors.second).copy(alpha = alpha)
- val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
- val activeNodeZIndex = if (isHighPriority) 5f else 4f
-
- if (index == sortedPositions.lastIndex) {
- MarkerComposable(
- state = markerState,
- zIndex = activeNodeZIndex,
- alpha = if (isHighPriority) 1.0f else 0.9f,
- ) {
- NodeChip(node = focusedNode)
- }
- } else {
- MarkerInfoWindowComposable(
- state = markerState,
- title = stringResource(Res.string.position),
- snippet = formatAgo(position.time),
- zIndex = 1f + alpha,
- infoContent = {
- PositionInfoWindowContent(position = position, displayUnits = displayUnits)
- },
- ) {
- Icon(
- imageVector = MeshtasticIcons.TripOrigin,
- contentDescription = stringResource(Res.string.track_point),
- tint = color,
- )
- }
- }
- }
- }
+ }
- if (sortedPositions.size > 1) {
- val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
- segments.forEachIndexed { index, segmentPoints ->
- val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
- Polyline(
- points = segmentPoints.map { it.toLatLng() },
- jointType = JointType.ROUND,
- color = Color(focusedNode.colors.second).copy(alpha = alpha),
- width = 8f,
- zIndex = 0.6f,
- )
- }
- }
- }
- } else {
- NodeClusterMarkers(
- nodeClusterItems = nodeClusterItems,
- mapFilterState = mapFilterState,
- navigateToNodeDetails = navigateToNodeDetails,
- onClusterClick = { cluster ->
- val items = cluster.items.toList()
- val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
-
- if (allSameLocation) {
- showClusterItemsDialog = items
- } else {
- val bounds = LatLngBounds.builder()
- cluster.items.forEach { bounds.include(it.position) }
- coroutineScope.launch {
- cameraPositionState.animate(
- CameraUpdateFactory.newCameraPosition(
- CameraPosition.Builder()
- .target(bounds.build().center)
- .zoom(cameraPositionState.position.zoom + 1)
- .build(),
- ),
- )
- }
- Logger.d { "Cluster clicked! $cluster" }
- }
- true
- },
- )
+ is GoogleMapMode.Traceroute ->
+ TracerouteMapContent(
+ forwardOffsetPoints = tracerouteForwardOffsetPoints,
+ returnOffsetPoints = tracerouteReturnOffsetPoints,
+ forwardPointCount = tracerouteForwardPoints.size,
+ returnPointCount = tracerouteReturnPoints.size,
+ displayNodes = tracerouteDisplayNodes,
+ )
}
-
- WaypointMarkers(
- displayableWaypoints = displayableWaypoints,
- mapFilterState = mapFilterState,
- myNodeNum = mapViewModel.myNodeNum ?: 0,
- isConnected = isConnected,
- unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
- onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit },
- selectedWaypointId = selectedWaypointId,
- )
-
- mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
}
+ // Scale bar
ScaleBar(
cameraPositionState = cameraPositionState,
- modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp),
+ modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp),
)
- editingWaypoint?.let { waypointToEdit ->
- EditWaypointDialog(
- waypoint = waypointToEdit,
- onSendClicked = { updatedWp ->
- var finalWp = updatedWp
- if (updatedWp.id == 0) {
- finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0)
- }
- if ((updatedWp.icon ?: 0) == 0) {
- finalWp = finalWp.copy(icon = 0x1F4CD)
- }
- mapViewModel.sendWaypoint(finalWp)
- editingWaypoint = null
- },
- onDeleteClicked = { wpToDelete ->
- if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) {
- val deleteMarkerWp = wpToDelete.copy(expire = 1)
- mapViewModel.sendWaypoint(deleteMarkerWp)
- }
- mapViewModel.deleteWaypoint(wpToDelete.id)
- editingWaypoint = null
- },
- onDismissRequest = { editingWaypoint = null },
- )
+ // Waypoint edit dialog (Main mode only)
+ if (isMainMode) {
+ editingWaypoint?.let { waypointToEdit ->
+ EditWaypointDialog(
+ waypoint = waypointToEdit,
+ onSendClicked = { updatedWp ->
+ var finalWp = updatedWp
+ if (updatedWp.id == 0) {
+ finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0)
+ }
+ if ((updatedWp.icon ?: 0) == 0) {
+ finalWp = finalWp.copy(icon = 0x1F4CD)
+ }
+ mapViewModel.sendWaypoint(finalWp)
+ editingWaypoint = null
+ },
+ onDeleteClicked = { wpToDelete ->
+ if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) {
+ mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
+ }
+ mapViewModel.deleteWaypoint(wpToDelete.id)
+ editingWaypoint = null
+ },
+ onDismissRequest = { editingWaypoint = null },
+ )
+ }
}
+ // Controls overlay
val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
val showRefresh = visibleNetworkLayers.isNotEmpty()
val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing }
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
- mapFilterMenuExpanded = mapFilterMenuExpanded,
- onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false },
- onToggleMapFilterMenu = { mapFilterMenuExpanded = true },
- mapViewModel = mapViewModel,
- mapTypeMenuExpanded = mapTypeMenuExpanded,
- onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false },
- onToggleMapTypeMenu = { mapTypeMenuExpanded = true },
- onManageLayersClicked = { showLayersBottomSheet = true },
- onManageCustomTileProvidersClicked = {
- mapTypeMenuExpanded = false
- showCustomTileManagerSheet = true
+ onToggleFilterMenu = { mapFilterMenuExpanded = true },
+ filterDropdownContent = {
+ if (mode is GoogleMapMode.NodeTrack) {
+ NodeMapFilterDropdown(
+ expanded = mapFilterMenuExpanded,
+ onDismissRequest = { mapFilterMenuExpanded = false },
+ mapViewModel = mapViewModel,
+ )
+ } else {
+ MapFilterDropdown(
+ expanded = mapFilterMenuExpanded,
+ onDismissRequest = { mapFilterMenuExpanded = false },
+ mapViewModel = mapViewModel,
+ )
+ }
+ },
+ mapTypeContent = {
+ Box {
+ MapButton(
+ icon = MeshtasticIcons.Map,
+ contentDescription = stringResource(Res.string.map_tile_source),
+ onClick = { mapTypeMenuExpanded = true },
+ )
+ MapTypeDropdown(
+ expanded = mapTypeMenuExpanded,
+ onDismissRequest = { mapTypeMenuExpanded = false },
+ mapViewModel = mapViewModel,
+ onManageCustomTileProvidersClicked = {
+ mapTypeMenuExpanded = false
+ showCustomTileManagerSheet = true
+ },
+ )
+ }
+ },
+ layersContent = {
+ MapButton(
+ icon = MeshtasticIcons.Layers,
+ contentDescription = stringResource(Res.string.manage_map_layers),
+ onClick = { showLayersBottomSheet = true },
+ )
},
- isNodeMap = focusedNodeNum != null,
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
@@ -681,6 +714,8 @@ fun MapView(
onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
)
}
+
+ // --- Bottom sheets & dialogs ---
if (showLayersBottomSheet) {
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
CustomMapLayersSheet(
@@ -710,16 +745,271 @@ fun MapView(
}
}
+// region --- Main Map Content ---
+
+@Suppress("LongParameterList")
+@OptIn(MapsComposeExperimentalApi::class)
+@Composable
+private fun MainMapContent(
+ nodeClusterItems: List<NodeClusterItem>,
+ mapFilterState: MapFilterState,
+ navigateToNodeDetails: (Int) -> Unit,
+ displayableWaypoints: List<Waypoint>,
+ myNodeNum: Int?,
+ isConnected: Boolean,
+ onEditWaypointRequest: (Waypoint) -> Unit,
+ selectedWaypointId: Int?,
+ mapLayers: List<MapLayerItem>,
+ mapViewModel: MapViewModel,
+ cameraPositionState: CameraPositionState,
+ coroutineScope: CoroutineScope,
+ onShowClusterItemsDialog: (List<NodeClusterItem>?) -> Unit,
+) {
+ NodeClusterMarkers(
+ nodeClusterItems = nodeClusterItems,
+ mapFilterState = mapFilterState,
+ navigateToNodeDetails = navigateToNodeDetails,
+ onClusterClick = { cluster ->
+ val items = cluster.items.toList()
+ val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
+ if (allSameLocation) {
+ onShowClusterItemsDialog(items)
+ } else {
+ val bounds = LatLngBounds.builder()
+ cluster.items.forEach { bounds.include(it.position) }
+ coroutineScope.launch {
+ cameraPositionState.animate(
+ CameraUpdateFactory.newCameraPosition(
+ CameraPosition.Builder()
+ .target(bounds.build().center)
+ .zoom(cameraPositionState.position.zoom + 1)
+ .build(),
+ ),
+ )
+ }
+ Logger.d { "Cluster clicked! $cluster" }
+ }
+ true
+ },
+ )
+
+ WaypointMarkers(
+ displayableWaypoints = displayableWaypoints,
+ mapFilterState = mapFilterState,
+ myNodeNum = myNodeNum ?: 0,
+ isConnected = isConnected,
+ unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
+ onEditWaypointRequest = onEditWaypointRequest,
+ selectedWaypointId = selectedWaypointId,
+ )
+
+ mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
+}
+
+// endregion
+
+// region --- Node Track Overlay ---
+
+/**
+ * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
+ * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
+ * [TripOrigin] dot with an info-window on tap.
+ */
+@OptIn(MapsComposeExperimentalApi::class)
+@Composable
+private fun NodeTrackOverlay(
+ focusedNode: Node,
+ sortedPositions: List<Position>,
+ displayUnits: DisplayUnits,
+ myNodeNum: Int?,
+) {
+ val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
+ val activeNodeZIndex = if (isHighPriority) 5f else 4f
+
+ sortedPositions.forEachIndexed { index, position ->
+ key(position.time) {
+ val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
+ val alpha =
+ if (sortedPositions.size > 1) {
+ index.toFloat() / (sortedPositions.size.toFloat() - 1)
+ } else {
+ 1f
+ }
+ val color = Color(focusedNode.colors.second).copy(alpha = alpha)
+
+ if (index == sortedPositions.lastIndex) {
+ MarkerComposable(
+ state = markerState,
+ zIndex = activeNodeZIndex,
+ alpha = if (isHighPriority) 1.0f else 0.9f,
+ ) {
+ NodeChip(node = focusedNode)
+ }
+ } else {
+ MarkerInfoWindowComposable(
+ state = markerState,
+ title = stringResource(Res.string.position),
+ snippet = formatAgo(position.time),
+ zIndex = 1f + alpha,
+ infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) },
+ ) {
+ Icon(
+ imageVector = MeshtasticIcons.TripOrigin,
+ contentDescription = stringResource(Res.string.track_point),
+ tint = color,
+ )
+ }
+ }
+ }
+ }
+
+ // Gradient polyline segments
+ if (sortedPositions.size > 1) {
+ val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
+ segments.forEachIndexed { index, segmentPoints ->
+ val alpha = index.toFloat() / (segments.size.toFloat() - 1)
+ Polyline(
+ points = segmentPoints.map { it.toLatLng() },
+ jointType = JointType.ROUND,
+ color = Color(focusedNode.colors.second).copy(alpha = alpha),
+ width = 8f,
+ zIndex = 0.6f,
+ )
+ }
+ }
+}
+
+@Composable
+@Suppress("LongMethod")
+private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) {
+ @Composable
+ fun PositionRow(label: String, value: String) {
+ Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(label, style = MaterialTheme.typography.labelMedium)
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(value, style = MaterialTheme.typography.labelMedium)
+ }
+ }
+
+ Card {
+ Column(modifier = Modifier.padding(8.dp)) {
+ PositionRow(
+ label = stringResource(Res.string.latitude),
+ value = "%.5f".format((position.latitude_i ?: 0) * DEG_D),
+ )
+ PositionRow(
+ label = stringResource(Res.string.longitude),
+ value = "%.5f".format((position.longitude_i ?: 0) * DEG_D),
+ )
+ PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString())
+ PositionRow(
+ label = stringResource(Res.string.alt),
+ value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits),
+ )
+ PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits))
+ PositionRow(
+ label = stringResource(Res.string.heading),
+ value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG),
+ )
+ PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime())
+ }
+ }
+}
+
+@Composable
+private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
+ val speedInMps = position.ground_speed ?: 0
+ val mpsText = "%d m/s".format(speedInMps)
+ return if (speedInMps > 10) {
+ when (displayUnits) {
+ DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
+ DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
+ else -> mpsText
+ }
+ } else {
+ mpsText
+ }
+}
+
+// endregion
+
+// region --- Traceroute Map Content ---
+
+@OptIn(MapsComposeExperimentalApi::class)
+@Composable
+private fun TracerouteMapContent(
+ forwardOffsetPoints: List<LatLng>,
+ returnOffsetPoints: List<LatLng>,
+ forwardPointCount: Int,
+ returnPointCount: Int,
+ displayNodes: List<Node>,
+) {
+ if (forwardPointCount >= 2) {
+ Polyline(
+ points = forwardOffsetPoints,
+ jointType = JointType.ROUND,
+ color = TracerouteColors.OutgoingRoute,
+ width = 9f,
+ zIndex = 3.0f,
+ )
+ }
+ if (returnPointCount >= 2) {
+ Polyline(
+ points = returnOffsetPoints,
+ jointType = JointType.ROUND,
+ color = TracerouteColors.ReturnRoute,
+ width = 7f,
+ zIndex = 2.5f,
+ )
+ }
+ displayNodes.forEach { node ->
+ val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
+ MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
+ }
+}
+
+private fun offsetPolyline(
+ points: List<LatLng>,
+ offsetMeters: Double,
+ headingReferencePoints: List<LatLng> = points,
+ sideMultiplier: Double = 1.0,
+): List<LatLng> {
+ val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
+ if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
+
+ val headings =
+ headingPoints.mapIndexed { index, _ ->
+ when (index) {
+ 0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
+ headingPoints.lastIndex ->
+ SphericalUtil.computeHeading(
+ headingPoints[headingPoints.lastIndex - 1],
+ headingPoints[headingPoints.lastIndex],
+ )
+
+ else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
+ }
+ }
+
+ return points.mapIndexed { index, point ->
+ val heading = headings[index.coerceIn(0, headings.lastIndex)]
+ val perpendicularHeading = heading + (90.0 * sideMultiplier)
+ SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading)
+ }
+}
+
+// endregion
+
+// region --- Map Layers ---
+
@Composable
private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
val context = LocalContext.current
- var currentLayer by remember { mutableStateOf<com.google.maps.android.data.Layer?>(null) }
+ var currentLayer by remember { mutableStateOf<Layer?>(null) }
MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
- // Cleanup old layer if we're reloading
currentLayer?.safeRemoveLayerFromMap()
currentLayer = null
-
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
val layer =
try {
@@ -732,11 +1022,8 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
null
}
-
layer?.let {
- if (layerItem.isVisible) {
- it.safeAddLayerToMap()
- }
+ if (layerItem.isVisible) it.safeAddLayerToMap()
currentLayer = it
}
}
@@ -748,38 +1035,32 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
}
}
- // Handle visibility changes without reloading the whole layer if possible,
- // though KmlLayer.addLayerToMap() / removeLayerFromMap() is what we have.
LaunchedEffect(layerItem.isVisible) {
val layer = currentLayer ?: return@LaunchedEffect
- if (layerItem.isVisible) {
- layer.safeAddLayerToMap()
- } else {
- layer.safeRemoveLayerFromMap()
- }
+ if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap()
}
}
-private fun com.google.maps.android.data.Layer.safeRemoveLayerFromMap() {
+private fun Layer.safeRemoveLayerFromMap() {
try {
removeLayerFromMap()
} catch (e: Exception) {
- // Log it and ignore. This specifically handles a NullPointerException in
- // KmlRenderer.hasNestedContainers which can occur when disposing layers.
Logger.withTag("MapView").e(e) { "Error removing map layer" }
}
}
-private fun com.google.maps.android.data.Layer.safeAddLayerToMap() {
+private fun Layer.safeAddLayerToMap() {
try {
- if (!isLayerOnMap) {
- addLayerToMap()
- }
+ if (!isLayerOnMap) addLayerToMap()
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error adding map layer" }
}
}
+// endregion
+
+// region --- Utilities ---
+
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
String(Character.toChars(unicodeCodePoint))
} catch (e: IllegalArgumentException) {
@@ -795,14 +1076,12 @@ internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
color = android.graphics.Color.BLACK
textAlign = Paint.Align.CENTER
}
-
val baseline = -paint.ascent()
val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt()
val height = (baseline + paint.descent() + 0.5f).toInt()
val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = Canvas(image)
canvas.drawText(unicodeEmoji, width / 2f, baseline, paint)
-
return BitmapDescriptorFactory.fromBitmap(image)
}
@@ -822,98 +1101,7 @@ fun Uri.getFileName(context: android.content.Context): String {
return name
}
-@Composable
-@Suppress("LongMethod")
-private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) {
- @Composable
- fun PositionRow(label: String, value: String) {
- Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
- Text(label, style = MaterialTheme.typography.labelMedium)
- Spacer(modifier = Modifier.width(16.dp))
- Text(value, style = MaterialTheme.typography.labelMedium)
- }
- }
-
- Card {
- Column(modifier = Modifier.padding(8.dp)) {
- PositionRow(
- label = stringResource(Res.string.latitude),
- value = "%.5f".format((position.latitude_i ?: 0) * DEG_D),
- )
-
- PositionRow(
- label = stringResource(Res.string.longitude),
- value = "%.5f".format((position.longitude_i ?: 0) * DEG_D),
- )
-
- PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view?.toString() ?: "")
-
- PositionRow(
- label = stringResource(Res.string.alt),
- value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits),
- )
-
- PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits))
-
- PositionRow(
- label = stringResource(Res.string.heading),
- value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG),
- )
-
- PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime())
- }
- }
-}
-
-@Composable
-private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
- val speedInMps = position.ground_speed ?: 0
- val mpsText = "%d m/s".format(speedInMps)
- val speedText =
- if (speedInMps > 10) {
- when (displayUnits) {
- DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
- DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
- else -> mpsText
- }
- } else {
- mpsText
- }
- return speedText
-}
-
+/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
-private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
-
-private fun Waypoint.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
-
-private fun offsetPolyline(
- points: List<LatLng>,
- offsetMeters: Double,
- headingReferencePoints: List<LatLng> = points,
- sideMultiplier: Double = 1.0,
-): List<LatLng> {
- val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
- if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
-
- val headings =
- headingPoints.mapIndexed { index, _ ->
- when (index) {
- 0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
- headingPoints.lastIndex ->
- SphericalUtil.computeHeading(
- headingPoints[headingPoints.lastIndex - 1],
- headingPoints[headingPoints.lastIndex],
- )
-
- else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
- }
- }
-
- return points.mapIndexed { index, point ->
- val heading = headings[index.coerceIn(0, headings.lastIndex)]
- val perpendicularHeading = heading + (90.0 * sideMultiplier)
- SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading)
- }
-}
+// endregion

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
index 9d9f79ec24..d8e29120e1 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
@@ -42,9 +42,9 @@ import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.ui.icon.Favorite
+import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.Place
-import org.meshtastic.core.ui.icon.RadioButtonUnchecked
+import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
@@ -73,7 +73,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
Icon(
- imageVector = MeshtasticIcons.Place,
+ imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
},
@@ -89,7 +89,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
- imageVector = MeshtasticIcons.RadioButtonUnchecked, // Placeholder icon
+ imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
index fdc5ee262d..d4a53dcc43 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
@@ -25,14 +25,13 @@ import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
+import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
-private const val DEG_D = 1e-7
-
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
index f6691b5ce8..fa17fedbfa 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
@@ -16,13 +16,14 @@
*/
package org.meshtastic.app.map.node
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
@@ -31,7 +32,6 @@ import org.meshtastic.feature.map.node.NodeMapViewModel
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
- val destNum = node?.num
Scaffold(
topBar = {
@@ -46,8 +46,9 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
- Box(modifier = Modifier.padding(paddingValues)) {
- MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
- }
+ MapView(
+ modifier = Modifier.fillMaxSize().padding(paddingValues),
+ mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
+ )
}
}

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
new file mode 100644
index 0000000000..513957c61b
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.node
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.app.map.GoogleMapMode
+import org.meshtastic.app.map.MapView
+import org.meshtastic.feature.map.node.NodeMapViewModel
+import org.meshtastic.proto.Position
+
+/**
+ * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a
+ * [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
+ * which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
+ * filter).
+ */
+@Composable
+fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
+ val vm = koinViewModel<NodeMapViewModel>()
+ vm.setDestNum(destNum)
+ val focusedNode by vm.node.collectAsStateWithLifecycle()
+ MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions))
+}

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
new file mode 100644
index 0000000000..d725537c82
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.traceroute
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.meshtastic.app.map.GoogleMapMode
+import org.meshtastic.app.map.MapView
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.proto.Position
+
+/**
+ * Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute]
+ * mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay).
+ */
+@Composable
+fun TracerouteMap(
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map<Int, Position>,
+ onMappableCountChanged: (shown: Int, total: Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ MapView(
+ modifier = modifier,
+ mode =
+ GoogleMapMode.Traceroute(
+ overlay = tracerouteOverlay,
+ nodePositions = tracerouteNodePositions,
+ onMappableCountChanged = onMappableCountChanged,
+ ),
+ )
+}

diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 8b3e85b9c3..342b845dd7 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -69,14 +69,24 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalInlineMapProvider
+import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
+import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
+import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
+import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
+import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
+import org.meshtastic.feature.map.MapScreen
+import org.meshtastic.feature.map.SharedMapViewModel
+import org.meshtastic.feature.map.node.NodeMapViewModel
+import org.meshtastic.feature.node.metrics.MetricsViewModel
+import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
@@ -164,32 +174,42 @@ class MainActivity : ComponentActivity() {
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
+ LocalNodeTrackMapProvider provides
+ { destNum, positions, modifier ->
+ org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier)
+ },
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
- org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides
+ LocalTracerouteMapProvider provides
+ { overlay, nodePositions, onMappableCountChanged, modifier ->
+ org.meshtastic.app.map.traceroute.TracerouteMap(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = nodePositions,
+ onMappableCountChanged = onMappableCountChanged,
+ modifier = modifier,
+ )
+ },
+ LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
- val vm = koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
+ val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
- org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
+ LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->
- val metricsViewModel =
- koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel> {
- org.koin.core.parameter.parametersOf(destNum)
- }
+ val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
- org.meshtastic.feature.node.metrics.TracerouteMapScreen(
+ TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
},
- org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides
+ LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
- val viewModel = koinViewModel<org.meshtastic.feature.map.SharedMapViewModel>()
- org.meshtastic.feature.map.MapScreen(
+ val viewModel = koinViewModel<SharedMapViewModel>()
+ MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt b/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt
similarity index 90%
rename from app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt
rename to app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt
index 0d5a79cdb4..997d7d08b7 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt
@@ -24,13 +24,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+/**
+ * A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance
+ * across both Google and F-Droid flavors.
+ */
@Composable
fun MapButton(
- modifier: Modifier = Modifier,
icon: ImageVector,
- iconTint: Color? = null,
contentDescription: String,
onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ iconTint: Color? = null,
) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt b/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
similarity index 56%
rename from app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
rename to app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
index 4f0b1afa3f..74f08e07fb 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
@@ -27,17 +27,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_filter
-import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
-import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.LocationDisabled
-import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
@@ -45,77 +40,58 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
+/**
+ * Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location
+ * tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh).
+ *
+ * @param onToggleFilterMenu Callback to open/close the filter dropdown.
+ * @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a
+ * `DropdownMenu` with filter options.
+ * @param mapTypeContent Optional composable for a map type selector button + dropdown. Google flavor provides map type
+ * and custom tile options; F-Droid provides a tile source selector.
+ * @param layersContent Optional composable for a layers management button.
+ * @param showRefresh Whether to show a refresh button (e.g., for network map layers).
+ * @param isRefreshing Whether a refresh is currently in progress.
+ * @param onRefresh Callback when the refresh button is clicked.
+ */
+@Suppress("LongParameterList")
@Composable
fun MapControlsOverlay(
+ onToggleFilterMenu: () -> Unit,
modifier: Modifier = Modifier,
- mapFilterMenuExpanded: Boolean,
- onMapFilterMenuDismissRequest: () -> Unit,
- onToggleMapFilterMenu: () -> Unit,
- mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
- mapTypeMenuExpanded: Boolean,
- onMapTypeMenuDismissRequest: () -> Unit,
- onToggleMapTypeMenu: () -> Unit,
- onManageLayersClicked: () -> Unit,
- onManageCustomTileProvidersClicked: () -> Unit, // New parameter
- isNodeMap: Boolean,
- // Location tracking parameters
- isLocationTrackingEnabled: Boolean = false,
- onToggleLocationTracking: () -> Unit = {},
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
- followPhoneBearing: Boolean,
+ followPhoneBearing: Boolean = false,
+ filterDropdownContent: @Composable () -> Unit = {},
+ mapTypeContent: @Composable () -> Unit = {},
+ layersContent: @Composable () -> Unit = {},
+ isLocationTrackingEnabled: Boolean = false,
+ onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
Row(modifier = modifier) {
+ // Compass
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
- if (isNodeMap) {
+
+ // Filter button + dropdown
+ Box {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
- onClick = onToggleMapFilterMenu,
- )
- NodeMapFilterDropdown(
- expanded = mapFilterMenuExpanded,
- onDismissRequest = onMapFilterMenuDismissRequest,
- mapViewModel = mapViewModel,
+ onClick = onToggleFilterMenu,
)
- } else {
- Box {
- MapButton(
- icon = MeshtasticIcons.Tune,
- contentDescription = stringResource(Res.string.map_filter),
- onClick = onToggleMapFilterMenu,
- )
- MapFilterDropdown(
- expanded = mapFilterMenuExpanded,
- onDismissRequest = onMapFilterMenuDismissRequest,
- mapViewModel = mapViewModel,
- )
- }
+ filterDropdownContent()
}
- Box {
- MapButton(
- icon = MeshtasticIcons.Map,
- contentDescription = stringResource(Res.string.map_tile_source),
- onClick = onToggleMapTypeMenu,
- )
- MapTypeDropdown(
- expanded = mapTypeMenuExpanded,
- onDismissRequest = onMapTypeMenuDismissRequest,
- mapViewModel = mapViewModel, // Pass mapViewModel
- onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
- )
- }
+ // Map type selector (flavor-specific)
+ mapTypeContent()
- MapButton(
- icon = MeshtasticIcons.Layers,
- contentDescription = stringResource(Res.string.manage_map_layers),
- onClick = onManageLayersClicked,
- )
+ // Layers button (flavor-specific)
+ layersContent()
+ // Refresh button (optional)
if (showRefresh) {
if (isRefreshing) {
Box(modifier = Modifier.padding(8.dp)) {
@@ -132,12 +108,7 @@ fun MapControlsOverlay(
// Location tracking button
MapButton(
- icon =
- if (isLocationTrackingEnabled) {
- MeshtasticIcons.LocationDisabled
- } else {
- MeshtasticIcons.MyLocation
- },
+ icon = if (isLocationTrackingEnabled) MeshtasticIcons.LocationDisabled else MeshtasticIcons.MyLocation,
contentDescription = stringResource(Res.string.toggle_my_position),
onClick = onToggleLocationTracking,
)
@@ -146,12 +117,16 @@ fun MapControlsOverlay(
@Composable
private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
- val icon = if (isFollowing) MeshtasticIcons.MapCompass else MeshtasticIcons.MapCompass
-
+ val iconTint =
+ when {
+ isFollowing -> MaterialTheme.colorScheme.primary
+ bearing == 0f -> MaterialTheme.colorScheme.StatusRed
+ else -> null
+ }
MapButton(
modifier = Modifier.rotate(-bearing),
- icon = icon,
- iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f },
+ icon = MeshtasticIcons.MapCompass,
+ iconTint = iconTint,
contentDescription = stringResource(Res.string.orient_north),
onClick = onClick,
)

diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt
similarity index 65%
rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt
index 7a9bb66274..97b5507ad1 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt
@@ -14,15 +14,24 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-package org.meshtastic.feature.map.model
+package org.meshtastic.core.model
+/**
+ * Represents a traceroute result with forward and return routes as ordered lists of node nums.
+ *
+ * @property requestId The mesh packet request ID that initiated this traceroute.
+ * @property forwardRoute Ordered node nums along the path towards the destination.
+ * @property returnRoute Ordered node nums along the return path back to the originator.
+ */
data class TracerouteOverlay(
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
) {
+ /** All unique node nums involved in either route direction. */
val relatedNodeNums: Set<Int> = (forwardRoute + returnRoute).toSet()
+ /** True if at least one route direction contains nodes. */
val hasRoutes: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt
new file mode 100644
index 0000000000..252297754a
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model.util
+
+/** Common geographic constants for coordinate conversions. */
+object GeoConstants {
+ /** Multiplier to convert protobuf integer coordinates (1e-7 degree units) to decimal degrees. */
+ const val DEG_D = 1e-7
+
+ /** Multiplier to convert protobuf integer heading values (1e-5 degree units) to decimal degrees. */
+ const val HEADING_DEG = 1e-5
+
+ /** Mean radius of the Earth in meters, for haversine calculations. */
+ const val EARTH_RADIUS_METERS = 6_371_000.0
+}

diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
index 0fdb4d48c2..ed28ebccd1 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
@@ -207,7 +207,7 @@ object DeepLinkRouter {
private val nodeDetailSubRoutes: Map<String, (Int) -> Route> =
mapOf(
"device-metrics" to { destNum -> NodeDetailRoute.DeviceMetrics(destNum) },
- "map" to { destNum -> NodeDetailRoute.NodeMap(destNum) },
+ "map" to { destNum -> NodeDetailRoute.PositionLog(destNum) },
"position" to { destNum -> NodeDetailRoute.PositionLog(destNum) },
"environment" to { destNum -> NodeDetailRoute.EnvironmentMetrics(destNum) },
"signal" to { destNum -> NodeDetailRoute.SignalMetrics(destNum) },

diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index fc288a04c2..7f43bf5498 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -74,8 +74,6 @@ sealed interface NodesRoute : Route {
sealed interface NodeDetailRoute : Route {
@Serializable data class DeviceMetrics(val destNum: Int) : NodeDetailRoute
- @Serializable data class NodeMap(val destNum: Int) : NodeDetailRoute
-
@Serializable data class PositionLog(val destNum: Int) : NodeDetailRoute
@Serializable data class EnvironmentMetrics(val destNum: Int) : NodeDetailRoute

diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
index a6ead26054..04bda74728 100644
--- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
@@ -156,7 +156,7 @@ class DeepLinkRouterTest {
listOf(
NodesRoute.NodesGraph,
NodesRoute.NodeDetailGraph(destNum = 5678),
- NodeDetailRoute.NodeMap(destNum = 5678),
+ NodeDetailRoute.PositionLog(destNum = 5678),
),
route("/nodes/5678/map"),
)

diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt
index e898796134..293c567fc7 100644
--- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt
@@ -62,7 +62,6 @@ class NavigationConfigTest {
NodesRoute.NodeDetail(),
// NodeDetailRoute
NodeDetailRoute.DeviceMetrics(destNum = 100),
- NodeDetailRoute.NodeMap(destNum = 100),
NodeDetailRoute.PositionLog(destNum = 100),
NodeDetailRoute.EnvironmentMetrics(destNum = 100),
NodeDetailRoute.SignalMetrics(destNum = 100),

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 7bb3a42ddf..5d7eba25a9 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -889,6 +889,12 @@
<string name="type_a_message">Type a message</string>
<string name="pax_metrics_log">PAX Metrics</string>
<string name="pax">PAX</string>
+ <string name="pax_total_format">PAX: %1$d</string>
+ <string name="pax_ble_format">B:%1$d</string>
+ <string name="pax_wifi_format">W:%1$d</string>
+ <string name="pax_total_marker">PAX: %1$s</string>
+ <string name="pax_ble_marker">BLE: %1$s</string>
+ <string name="pax_wifi_marker">WiFi: %1$s</string>
<string name="no_pax_metrics_logs">No PAX metrics available.</string>
<string name="wifi_devices">Wi-Fi Provisioning for mPWRD-OS</string>
<string name="ble_devices">Bluetooth Devices</string>

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt
new file mode 100644
index 0000000000..5ac8eca5a4
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.Modifier
+import org.meshtastic.core.ui.component.PlaceholderScreen
+import org.meshtastic.proto.Position
+
+/**
+ * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions].
+ * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded
+ * inside another screen layout (e.g. the position-log adaptive layout).
+ *
+ * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
+ */
+@Suppress("Wrapping")
+val LocalNodeTrackMapProvider =
+ compositionLocalOf<@Composable (destNum: Int, positions: List<Position>, modifier: Modifier) -> Unit> {
+ { _, _, _ -> PlaceholderScreen("Position Track Map") }
+ }

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt
new file mode 100644
index 0000000000..139992c540
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.Modifier
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.core.ui.component.PlaceholderScreen
+import org.meshtastic.proto.Position
+
+/**
+ * Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a
+ * traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location
+ * tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s
+ * scaffold.
+ *
+ * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
+ *
+ * Parameters:
+ * - `tracerouteOverlay`: The overlay with forward/return route node nums.
+ * - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes.
+ * - `onMappableCountChanged`: Callback with (shown, total) node counts.
+ * - `modifier`: Compose modifier for the map.
+ */
+@Suppress("Wrapping")
+val LocalTracerouteMapProvider =
+ compositionLocalOf<
+ @Composable (
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map<Int, Position>,
+ onMappableCountChanged: (Int, Int) -> Unit,
+ modifier: Modifier,
+ ) -> Unit,
+ > {
+ { _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
+ }

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
index 4561886e2e..10d975f3d4 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
@@ -22,23 +22,10 @@ import androidx.compose.ui.Modifier
/**
* Interface for providing a flavored MapView. This allows the map feature to be decoupled from specific map
- * implementations (Google Maps vs osmdroid).
+ * implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin.
*/
interface MapViewProvider {
- @Composable
- fun MapView(
- modifier: Modifier,
- // We use Any here to avoid circular dependency with feature:map
- viewModel: Any,
- navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int? = null,
- // Using List<Any> to avoid dependency on proto.Position if needed
- nodeTracks: List<Any>? = null,
- tracerouteOverlay: Any? = null,
- tracerouteNodePositions: Map<Int, Any> = emptyMap(),
- onTracerouteMappableCountChanged: (Int, Int) -> Unit = { _, _ -> },
- waypointId: Int? = null,
- )
+ @Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null)
}
val LocalMapViewProvider = compositionLocalOf<MapViewProvider?> { null }

diff --git a/docs/agent-playbooks/kmp-source-set-bridging-playbook.md b/docs/agent-playbooks/kmp-source-set-bridging-playbook.md
index e5e11da0b3..62753020af 100644
--- a/docs/agent-playbooks/kmp-source-set-bridging-playbook.md
+++ b/docs/agent-playbooks/kmp-source-set-bridging-playbook.md
@@ -26,7 +26,9 @@ Examples in current code:
- Platform/flavor UI implementations should be injected via `CompositionLocal` from app.
Examples:
-- Contract: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
+- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
+- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt`
+- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt`
- Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`
## 4) DI and module activation checks

diff --git a/docs/agent-playbooks/task-playbooks.md b/docs/agent-playbooks/task-playbooks.md
index 4a32623fb8..25a856d9f2 100644
--- a/docs/agent-playbooks/task-playbooks.md
+++ b/docs/agent-playbooks/task-playbooks.md
@@ -15,6 +15,8 @@ Key files for discovering established patterns:
| Shared ViewModel | `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` |
| `CompositionLocal` platform injection | `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` |
| Platform abstraction contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` |
+| Node track map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` |
+| Traceroute map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` |
| Shared strings resource | `core/resources/src/commonMain/composeResources/values/strings.xml` |
| Okio shared I/O | `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt` |
| `stateInWhileSubscribed` | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt` |
@@ -82,7 +84,9 @@ Reference examples:
4. Keep adapter types narrow and stable (interfaces, DTO-like params).
Reference examples:
-- Contract: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
+- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
+- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt`
+- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt`
- Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`
- Consumer side: `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt`

diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md
index ce5becbb23..3d09d68f3a 100644
--- a/docs/decisions/architecture-review-2026-03.md
+++ b/docs/decisions/architecture-review-2026-03.md
@@ -11,7 +11,7 @@ The codebase is **~98% structurally KMP** — 18/20 core modules and 8/8 feature
Of the five structural gaps originally identified, four are resolved and one remains in progress:
-1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 6 files: `MainActivity`, `MeshUtilApplication`, Nav shell, and DI config)*
+1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 8 files: `MainActivity`, `MeshUtilApplication`, Nav shell, DI config, and shared map UI components)*
2. ~~**Radio transport layer is app-locked**~~ — ✅ Resolved: `RadioTransport` interface in `core:repository/commonMain`; shared `StreamFrameCodec` + `TcpTransport` in `core:network`.
3. ~~**`java.*` APIs leak into `commonMain`**~~ — ✅ Resolved: `Locale`, `ConcurrentHashMap`, `ReentrantLock` purged.
4. ~~**Zero feature-level `commonTest`**~~ — ✅ Resolved: 193 shared tests across all 8 features; `core:testing` module established.
@@ -24,7 +24,7 @@ Of the five structural gaps originally identified, four are resolved and one rem
| `core/*/commonMain` | 337 | 32,700 | Shared business/data logic |
| `feature/*/commonMain` | 146 | 19,700 | Shared feature UI + ViewModels |
| `feature/*/androidMain` | 62 | 14,700 | Platform UI (charts, previews, permissions) |
-| `app/src/main` | 6 | ~300 | Android app shell (target achieved) |
+| `app/src/main` | 8 | ~450 | Android app shell + shared map UI components |
| `desktop/src` | 26 | 4,800 | Desktop app shell |
| `core/*/androidMain` | 49 | 3,500 | Platform implementations |
| `core/*/jvmMain` | 11 | ~500 | JVM actuals |
@@ -38,7 +38,7 @@ Of the five structural gaps originally identified, four are resolved and one rem
### A1. `app` module is a God module
-The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host. Originally it held **90 files / ~11K LOC**, now completely reduced to a **6-file shell**:
+The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host, and shared flavor-agnostic UI. Originally it held **90 files / ~11K LOC**, now reduced to an **8-file shell** (6 original + 2 shared map UI components: `MapButton`, `MapControlsOverlay`):
| Area | Files | LOC | Where it should live |
|---|---:|---:|---|

diff --git a/docs/kmp-status.md b/docs/kmp-status.md
index c5362e479e..95e4b6945c 100644
--- a/docs/kmp-status.md
+++ b/docs/kmp-status.md
@@ -1,6 +1,6 @@
# KMP Migration Status
-> Last updated: 2026-03-31
+> Last updated: 2026-04-10
Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/).
@@ -49,7 +49,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
| `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; fully shared `contactsGraph`, `MessageScreen`, `ContactsScreen`, and `MessageListPaged` |
| `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection |
| `feature:intro` | — | — | Screens remain in `androidMain`; shared ViewModel only |
-| `feature:map` | — | Placeholder; shared `NodeMapViewModel` and `BaseMapViewModel` only |
+| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`, and `TracerouteNodeSelection`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` |
| `feature:firmware` | ✅ | ✅ Fully KMP: Unified OTA, native Secure DFU, USB/UF2, FirmwareRetriever |
| `feature:wifi-provision` | ✅ | ✅ KMP WiFi provisioning via BLE (Nymea protocol); shared UI and ViewModel |
| `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. |
@@ -144,6 +144,8 @@ Extracted to shared `commonMain` (no longer app-only):
- `ChannelViewModel` → `feature:settings/commonMain`
- `NodeMapViewModel` → `feature:map/commonMain` (Shared logic for node-specific maps)
- `BaseMapViewModel` → `feature:map/commonMain` (Core contract for all maps)
+- `TracerouteOverlay` → `core:model/commonMain` (Pure data class for traceroute route segments; extracted from `feature:map` for cross-module reuse)
+- `GeoConstants` → `core:model/commonMain` (Centralized `DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS` constants; eliminates 7 duplicate private constants)
Extracted to core KMP modules:
- Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain`
@@ -151,7 +153,7 @@ Extracted to core KMP modules:
- TCP radio connections, BLE radio connections (`BleRadioInterface`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations)
Remaining to be extracted from `:app` or unified in `commonMain`:
-- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface)
+- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface. `MapViewProvider` interface simplified — track rendering and traceroute rendering extracted to dedicated provider contracts)
## Prerelease Dependencies

diff --git a/docs/roadmap.md b/docs/roadmap.md
index d7412c2ccb..91d051f9fd 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap
-> Last updated: 2026-03-31
+> Last updated: 2026-04-10
Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). For the full gap analysis, see [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md).
@@ -81,10 +81,10 @@ These items address structural gaps identified in the March 2026 architecture re
1. **Evaluate KMP-native testing tools** — ✅ **Done:** Fully evaluated and integrated `Mokkery`, `Turbine`, and `Kotest` across the KMP modules. `mockk` has been successfully replaced, enabling property-based and Flow testing in `commonTest` for iOS readiness.
2. **Desktop Map Integration** — Address the major Desktop feature gap by implementing a raster map view using [**MapComposeMP**](https://github.com/p-lr/MapComposeMP).
- - Implement a `MapComposeProvider` for Desktop.
+ - Implement Desktop providers for the 3 decomposed map contracts: `MapViewProvider` (main map), `NodeTrackMapProvider` (per-node track overlay for `PositionLogScreen`), and `TracerouteMapProvider` (traceroute visualization).
- Implement a **Web Mercator Projection** helper in `feature:map/commonMain` to translate GPS coordinates to the 2D image plane.
- - Leverage the existing `BaseMapViewModel` contract.
-3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface.
+ - Leverage the existing `BaseMapViewModel` contract and `TracerouteNodeSelection` logic in `commonMain`.
+3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface. The `MapViewProvider` interface has been simplified (track/traceroute rendering extracted to dedicated providers), reducing the surface area of this unification.
4. **iOS CI gate** — ✅ **Done:** added `iosArm64()`/`iosSimulatorArm64()` to convention plugins and CI. `commonMain` successfully compiles on iOS.
## Medium-Term Priorities (60 days)

diff --git a/feature/map/README.md b/feature/map/README.md
index 3e38406a92..802f18913c 100644
--- a/feature/map/README.md
+++ b/feature/map/README.md
@@ -1,24 +1,41 @@
# `:feature:map`
## Overview
-The `:feature:map` module provides the mapping interface for the application. It supports multiple map providers and displays node positions, tracks, and waypoints.
+The `:feature:map` module provides the mapping interface for the application. Map rendering is decomposed into three focused `CompositionLocal` provider contracts, each with per-flavor implementations in `:app`.
-## Key Components
+## Architecture
-### 1. `MapScreen`
-The main mapping interface. It integrates with flavor-specific map implementations (Google Maps for `google`, OpenStreetMap for `fdroid`).
+### Provider Contracts (in `core:ui/commonMain`)
-### 2. `BaseMapViewModel`
-The base logic for managing map state, node markers, and camera positions.
+| Contract | Purpose | Implementations |
+|---|---|---|
+| `MapViewProvider` | Main map (nodes, waypoints, controls) | `GoogleMapViewProvider`, `FdroidMapViewProvider` |
+| `NodeTrackMapProvider` | Per-node GPS track overlay (embedded in `PositionLogScreen`) | Google: `NodeTrackMap` → `MapView(GoogleMapMode.NodeTrack)`, F-Droid: `NodeTrackMap` → `NodeTrackOsmMap` |
+| `TracerouteMapProvider` | Traceroute route visualization | Google: `TracerouteMap` → `MapView(GoogleMapMode.Traceroute)`, F-Droid: `TracerouteMap` → `TracerouteOsmMap` |
+
+All providers are injected via `CompositionLocal` in `MainActivity.kt` and consumed by feature modules without direct dependency on Google Maps or osmdroid.
+
+### Shared ViewModels (in `commonMain`)
+
+- **`BaseMapViewModel`** — Core contract for all map state management, node markers, camera positions, and traceroute node selection logic (`TracerouteNodeSelection`, `tracerouteNodeSelection()`).
+- **`NodeMapViewModel`** — Shared logic for per-node map views (track display, position history).
+
+### Key Data Types
+
+- **`TracerouteOverlay`** (`core:model/commonMain`) — Pure data class representing traceroute route segments. Extracted from `feature:map` for cross-module reuse.
+- **`TracerouteNodeSelection`** (`feature:map/commonMain`) — Data class modeling node selection results during traceroute visualization.
+- **`GeoConstants`** (`core:model/commonMain`) — Centralized geographic constants (`DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS`).
## Map Providers
-- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK.
-- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source mapping experience.
+- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK. Implementations in `app/src/google/kotlin/org/meshtastic/app/map/`.
+- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source experience. Implementations in `app/src/fdroid/kotlin/org/meshtastic/app/map/`.
## Features
- **Live Node Tracking**: Real-time position updates for nodes on the mesh.
- **Waypoints**: Create and share points of interest.
+- **Per-Node Track Overlay**: Embedded map in `PositionLogScreen` showing a node's GPS track history.
+- **Traceroute Visualization**: Dedicated map view showing route segments between mesh nodes.
- **Offline Maps**: Support for pre-downloaded map tiles (via `osmdroid`).
## Module dependency graph

diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
index a018ca8e64..588ca198b8 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
@@ -57,7 +57,6 @@ fun MapScreen(
) { paddingValues ->
LocalMapViewProvider.current?.MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
- viewModel = viewModel,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)

diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
index e637b0d76f..a1a31dbf45 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
@@ -31,6 +31,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -41,7 +42,6 @@ import org.meshtastic.core.resources.one_day
import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
-import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@@ -194,16 +194,42 @@ open class BaseMapViewModel(
)
}
+/**
+ * Result of resolving a [TracerouteOverlay]'s node nums into displayable [Node] instances.
+ *
+ * @property overlayNodeNums All unique node nums referenced by the traceroute.
+ * @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available).
+ * @property nodeLookup Node-num-keyed map for polyline coordinate resolution.
+ */
data class TracerouteNodeSelection(
val overlayNodeNums: Set<Int>,
val nodesForMarkers: List<Node>,
val nodeLookup: Map<Int, Node>,
)
+/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */
fun BaseMapViewModel.tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
nodes: List<Node>,
+): TracerouteNodeSelection = tracerouteNodeSelection(
+ tracerouteOverlay = tracerouteOverlay,
+ tracerouteNodePositions = tracerouteNodePositions,
+ nodes = nodes,
+ getNodeOrFallback = ::getNodeOrFallback,
+)
+
+/**
+ * Resolves traceroute overlay node nums into displayable [Node] instances. Snapshot positions (recorded at traceroute
+ * time) take priority over live positions from the node database.
+ *
+ * @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB.
+ */
+fun tracerouteNodeSelection(
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map<Int, Position>,
+ nodes: List<Node>,
+ getNodeOrFallback: (Int) -> Node,
): TracerouteNodeSelection {
val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet()
val tracerouteSnapshotNodes =

diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt
new file mode 100644
index 0000000000..052e85da96
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.map
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@Suppress("MagicNumber")
+class LastHeardFilterTest {
+
+ @Test
+ fun fromSeconds_knownValues() {
+ assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(0L))
+ assertEquals(LastHeardFilter.OneHour, LastHeardFilter.fromSeconds(3600L))
+ assertEquals(LastHeardFilter.EightHours, LastHeardFilter.fromSeconds(28800L))
+ assertEquals(LastHeardFilter.OneDay, LastHeardFilter.fromSeconds(86400L))
+ assertEquals(LastHeardFilter.TwoDays, LastHeardFilter.fromSeconds(172800L))
+ }
+
+ @Test
+ fun fromSeconds_unknownValue_defaultsToAny() {
+ assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(9999L))
+ assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(-1L))
+ assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(Long.MAX_VALUE))
+ }
+
+ @Test
+ fun seconds_matchExpectedValues() {
+ assertEquals(0L, LastHeardFilter.Any.seconds)
+ assertEquals(3600L, LastHeardFilter.OneHour.seconds)
+ assertEquals(28800L, LastHeardFilter.EightHours.seconds)
+ assertEquals(86400L, LastHeardFilter.OneDay.seconds)
+ assertEquals(172800L, LastHeardFilter.TwoDays.seconds)
+ }
+}

diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt
new file mode 100644
index 0000000000..76ae250663
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt
@@ -0,0 +1,214 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.map
+
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.proto.Position
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class TracerouteNodeSelectionTest {
+
+ private fun nodeWithPosition(num: Int, latI: Int = num * 100000, lonI: Int = num * 200000): Node =
+ Node(num = num, position = Position(latitude_i = latI, longitude_i = lonI))
+
+ private fun nodeWithoutPosition(num: Int): Node = Node(num = num, position = Position())
+
+ private val defaultGetNodeOrFallback: (Int) -> Node = { num -> Node(num = num) }
+
+ // ---- Null overlay (no traceroute active) ----
+
+ @Test
+ fun nullOverlay_returnsAllNodesUnfiltered() {
+ val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3))
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = null,
+ tracerouteNodePositions = emptyMap(),
+ nodes = nodes,
+ getNodeOrFallback = defaultGetNodeOrFallback,
+ )
+
+ assertEquals(emptySet(), result.overlayNodeNums)
+ assertEquals(3, result.nodesForMarkers.size)
+ assertEquals(nodes.map { it.num }.toSet(), result.nodesForMarkers.map { it.num }.toSet())
+ }
+
+ @Test
+ fun nullOverlay_nodeLookupContainsOnlyNodesWithValidPositions() {
+ val nodes = listOf(nodeWithPosition(1), nodeWithoutPosition(2), nodeWithPosition(3))
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = null,
+ tracerouteNodePositions = emptyMap(),
+ nodes = nodes,
+ getNodeOrFallback = defaultGetNodeOrFallback,
+ )
+
+ // nodeLookup filters to validPosition nodes when no snapshot
+ assertEquals(setOf(1, 3), result.nodeLookup.keys)
+ }
+
+ // ---- Overlay with snapshot positions ----
+
+ @Test
+ fun overlayWithSnapshot_usesSnapshotPositions() {
+ val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(20, 10))
+ val snapshotPositions =
+ mapOf(
+ 10 to Position(latitude_i = 400000000, longitude_i = -700000000),
+ 20 to Position(latitude_i = 410000000, longitude_i = -710000000),
+ )
+ val liveNodes =
+ listOf(
+ nodeWithPosition(10, latI = 100000000, lonI = -100000000),
+ nodeWithPosition(20, latI = 200000000, lonI = -200000000),
+ nodeWithPosition(30),
+ )
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = snapshotPositions,
+ nodes = liveNodes,
+ getNodeOrFallback = { num -> liveNodes.find { it.num == num } ?: Node(num = num) },
+ )
+
+ // Should use snapshot positions, not live ones
+ assertEquals(setOf(10, 20), result.overlayNodeNums)
+ assertEquals(2, result.nodesForMarkers.size)
+ assertEquals(400000000, result.nodesForMarkers.first { it.num == 10 }.position.latitude_i)
+ assertEquals(410000000, result.nodesForMarkers.first { it.num == 20 }.position.latitude_i)
+ }
+
+ @Test
+ fun overlayWithSnapshot_nodeLookupUsesSnapshotNodes() {
+ val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
+ val snapshotPositions =
+ mapOf(
+ 10 to Position(latitude_i = 400000000, longitude_i = -700000000),
+ 20 to Position(latitude_i = 410000000, longitude_i = -710000000),
+ )
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = snapshotPositions,
+ nodes = emptyList(),
+ getNodeOrFallback = { num -> Node(num = num) },
+ )
+
+ assertEquals(2, result.nodeLookup.size)
+ assertEquals(400000000, result.nodeLookup[10]?.position?.latitude_i)
+ }
+
+ @Test
+ fun overlayWithSnapshot_filtersToOverlayNodes() {
+ // Snapshot has node 30 which is NOT in the overlay routes
+ val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
+ val snapshotPositions =
+ mapOf(
+ 10 to Position(latitude_i = 400000000, longitude_i = -700000000),
+ 20 to Position(latitude_i = 410000000, longitude_i = -710000000),
+ 30 to Position(latitude_i = 420000000, longitude_i = -720000000),
+ )
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = snapshotPositions,
+ nodes = emptyList(),
+ getNodeOrFallback = { num -> Node(num = num) },
+ )
+
+ // nodesForMarkers should only contain nodes in the overlay (10, 20), not 30
+ assertEquals(setOf(10, 20), result.nodesForMarkers.map { it.num }.toSet())
+ // but nodeLookup has all snapshot nodes (for polyline drawing)
+ assertEquals(3, result.nodeLookup.size)
+ }
+
+ // ---- Overlay without snapshot positions (live fallback) ----
+
+ @Test
+ fun overlayWithoutSnapshot_filtersLiveNodesToOverlayNums() {
+ val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(30))
+ val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20), nodeWithPosition(30), nodeWithPosition(40))
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = emptyMap(),
+ nodes = liveNodes,
+ getNodeOrFallback = defaultGetNodeOrFallback,
+ )
+
+ assertEquals(setOf(10, 20, 30), result.overlayNodeNums)
+ assertEquals(setOf(10, 20, 30), result.nodesForMarkers.map { it.num }.toSet())
+ }
+
+ @Test
+ fun overlayWithoutSnapshot_nodeLookupFiltersToValidPositions() {
+ val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
+ val liveNodes = listOf(nodeWithPosition(10), nodeWithoutPosition(20))
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = emptyMap(),
+ nodes = liveNodes,
+ getNodeOrFallback = defaultGetNodeOrFallback,
+ )
+
+ // nodeLookup only includes nodes with validPosition
+ assertEquals(setOf(10), result.nodeLookup.keys)
+ }
+
+ // ---- Edge cases ----
+
+ @Test
+ fun emptyOverlayRoutes_yieldsEmptySelection() {
+ val overlay = TracerouteOverlay(requestId = 1, forwardRoute = emptyList(), returnRoute = emptyList())
+ val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20))
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = emptyMap(),
+ nodes = liveNodes,
+ getNodeOrFallback = defaultGetNodeOrFallback,
+ )
+
+ assertTrue(result.overlayNodeNums.isEmpty())
+ assertTrue(result.nodesForMarkers.isEmpty())
+ }
+
+ @Test
+ fun getNodeOrFallback_usedForSnapshotNodeLookup() {
+ val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10))
+ val snapshotPositions = mapOf(10 to Position(latitude_i = 400000000, longitude_i = -700000000))
+ var lookupCalledWith: Int? = null
+ val result =
+ tracerouteNodeSelection(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = snapshotPositions,
+ nodes = emptyList(),
+ getNodeOrFallback = { num ->
+ lookupCalledWith = num
+ Node(num = num)
+ },
+ )
+
+ assertEquals(10, lookupCalledWith)
+ assertEquals(1, result.nodesForMarkers.size)
+ }
+}

diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt
index c198812802..2e0cbaed7e 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.map.model
+import org.meshtastic.core.model.TracerouteOverlay
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse

diff --git a/feature/node/component/DeviceActions.kt b/feature/node/component/DeviceActions.kt
deleted file mode 100644
index 7f652cca63..0000000000
--- a/feature/node/component/DeviceActions.kt
+++ /dev/null
@@ -1,261 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package org.meshtastic.feature.node.component
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import org.meshtastic.core.ui.icon.Delete
-import org.meshtastic.core.ui.icon.Favorite
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.Message
-import org.meshtastic.core.ui.icon.NotFavorite
-import org.meshtastic.core.ui.icon.QrCode2
-import org.meshtastic.core.ui.icon.VolumeMute
-import org.meshtastic.core.ui.icon.VolumeOff
-import org.meshtastic.core.ui.icon.VolumeUp
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ElevatedCard
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconToggleButton
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.actions
-import org.meshtastic.core.resources.direct_message
-import org.meshtastic.core.resources.favorite
-import org.meshtastic.core.resources.ignore
-import org.meshtastic.core.resources.mute_always
-import org.meshtastic.core.resources.remove
-import org.meshtastic.core.resources.share_contact
-import org.meshtastic.core.resources.unmute
-import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.component.SwitchListItem
-import org.meshtastic.feature.node.model.NodeDetailAction
-import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
-
-@Composable
-fun DeviceActions(
- node: Node,
- lastTracerouteTime: Long?,
- lastRequestNeighborsTime: Long?,
- onAction: (NodeDetailAction) -> Unit,
- modifier: Modifier = Modifier,
- isLocal: Boolean = false,
-) {
- var displayFavoriteDialog by remember { mutableStateOf(false) }
- var displayIgnoreDialog by remember { mutableStateOf(false) }
- var displayMuteDialog by remember { mutableStateOf(false) }
- var displayRemoveDialog by remember { mutableStateOf(false) }
-
- NodeActionDialogs(
- node = node,
- displayFavoriteDialog = displayFavoriteDialog,
- displayIgnoreDialog = displayIgnoreDialog,
- displayMuteDialog = displayMuteDialog,
- displayRemoveDialog = displayRemoveDialog,
- onDismissMenuRequest = {
- displayFavoriteDialog = false
- displayIgnoreDialog = false
- displayMuteDialog = false
- displayRemoveDialog = false
- },
- onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
- onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
- onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) },
- onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
- )
-
- ElevatedCard(
- modifier = modifier.fillMaxWidth(),
- colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- shape = MaterialTheme.shapes.extraLarge,
- ) {
- DeviceActionsContent(
- node = node,
- isLocal = isLocal,
- lastTracerouteTime = lastTracerouteTime,
- lastRequestNeighborsTime = lastRequestNeighborsTime,
- onAction = onAction,
- onFavoriteClick = { displayFavoriteDialog = true },
- onIgnoreClick = { displayIgnoreDialog = true },
- onMuteClick = { displayMuteDialog = true },
- onRemoveClick = { displayRemoveDialog = true },
- )
- }
-}
-
-@Composable
-private fun DeviceActionsContent(
- node: Node,
- isLocal: Boolean,
- lastTracerouteTime: Long?,
- lastRequestNeighborsTime: Long?,
- onAction: (NodeDetailAction) -> Unit,
- onFavoriteClick: () -> Unit,
- onIgnoreClick: () -> Unit,
- onMuteClick: () -> Unit,
- onRemoveClick: () -> Unit,
-) {
- Column(modifier = Modifier.padding(vertical = 12.dp)) {
- Text(
- text = stringResource(Res.string.actions),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
- )
-
- PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick)
-
- if (!isLocal) {
- HorizontalDivider(
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
- )
-
- RemoteDeviceActions(
- node = node,
- lastTracerouteTime = lastTracerouteTime,
- lastRequestNeighborsTime = lastRequestNeighborsTime,
- onAction = onAction,
- )
- }
-
- HorizontalDivider(
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
- )
-
- ManagementActions(node, onIgnoreClick, onMuteClick, onRemoveClick)
- }
-}
-
-@Composable
-private fun PrimaryActionsRow(
- node: Node,
- isLocal: Boolean,
- onAction: (NodeDetailAction) -> Unit,
- onFavoriteClick: () -> Unit,
-) {
- Row(
- modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- if (!node.isEffectivelyUnmessageable && !isLocal) {
- Button(
- onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
- modifier = Modifier.weight(1f),
- shape = MaterialTheme.shapes.large,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
- ),
- ) {
- Icon(MeshtasticIcons.Message, contentDescription = null)
- Spacer(Modifier.width(8.dp))
- Text(stringResource(Res.string.direct_message))
- }
- }
-
- OutlinedButton(
- onClick = { onAction(NodeDetailAction.ShareContact) },
- modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier,
- shape = MaterialTheme.shapes.large,
- ) {
- Icon(MeshtasticIcons.QrCode2, contentDescription = null)
- if (node.isEffectivelyUnmessageable || isLocal) {
- Spacer(Modifier.width(8.dp))
- Text(stringResource(Res.string.share_contact))
- }
- }
-
- IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
- Icon(
- imageVector = if (node.isFavorite) MeshtasticIcons.Favorite else MeshtasticIcons.NotFavorite,
- contentDescription = stringResource(Res.string.favorite),
- tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
- )
- }
- }
-}
-
-@Composable
-private fun ManagementActions(
- node: Node,
- onIgnoreClick: () -> Unit,
- onMuteClick: () -> Unit,
- onRemoveClick: () -> Unit,
-) {
- Column {
- SwitchListItem(
- text = stringResource(Res.string.ignore),
- leadingIcon =
- if (node.isIgnored) {
- MeshtasticIcons.VolumeMute
- } else {
- MeshtasticIcons.VolumeUp
- },
- checked = node.isIgnored,
- onClick = onIgnoreClick,
- )
-
- SwitchListItem(
- text = stringResource(if (node.isMuted) Res.string.unmute else Res.string.mute_always),
- leadingIcon = if (node.isMuted) {
- MeshtasticIcons.VolumeOff
- } else {
- MeshtasticIcons.VolumeUp
- },
- checked = node.isMuted,
- onClick = onMuteClick,
- )
-
- ListItem(
- text = stringResource(Res.string.remove),
- leadingIcon = MeshtasticIcons.Delete,
- trailingIcon = null,
- textColor = MaterialTheme.colorScheme.error,
- leadingIconTint = MaterialTheme.colorScheme.error,
- onClick = onRemoveClick,
- )
- }
-}

diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
index ec3cf5ea5b..8a4c0d7d57 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.traceroute
@@ -51,9 +52,8 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.TracerouteColors
-import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
-import org.meshtastic.feature.map.model.TracerouteOverlay
+import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.proto.Position
@Composable
@@ -117,16 +117,14 @@ private fun TracerouteMapScaffold(
},
) { paddingValues ->
Box(modifier = modifier.fillMaxSize().padding(paddingValues)) {
- LocalMapViewProvider.current?.MapView(
- modifier = Modifier,
- viewModel = Unit,
- navigateToNodeDetails = {},
- tracerouteOverlay = overlay,
- tracerouteNodePositions = snapshotPositions,
- onTracerouteMappableCountChanged = { shown: Int, total: Int ->
+ LocalTracerouteMapProvider.current(
+ overlay,
+ snapshotPositions,
+ { shown: Int, total: Int ->
tracerouteNodesShown = shown
tracerouteNodesTotal = total
},
+ Modifier.fillMaxSize(),
)
Column(
modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding),

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
index 26164c77b5..a0a9290fe8 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
@@ -58,18 +58,20 @@ import org.meshtastic.core.ui.icon.VolumeMute
import org.meshtastic.core.ui.icon.VolumeOff
import org.meshtastic.core.ui.icon.VolumeUp
import org.meshtastic.feature.node.model.LogsType
-import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
+import org.meshtastic.proto.Config
@Composable
fun DeviceActions(
node: Node,
+ ourNode: Node?,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
- metricsState: MetricsState,
+ displayUnits: Config.DisplayConfig.DisplayUnits,
+ isFahrenheit: Boolean,
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
@@ -85,10 +87,12 @@ fun DeviceActions(
TelemetricActionsSection(
node = node,
+ ourNode = ourNode,
availableLogs = availableLogs,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
- metricsState = metricsState,
+ displayUnits = displayUnits,
+ isFahrenheit = isFahrenheit,
onAction = onAction,
isLocal = isLocal,
)

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
new file mode 100644
index 0000000000..4d9287becc
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+@file:Suppress("TooManyFunctions", "MagicNumber")
+
+package org.meshtastic.feature.node.component
+
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.feature.node.model.LogsType
+import org.meshtastic.proto.Config
+
+// ---------------------------------------------------------------------------
+// Sample data for previews
+// ---------------------------------------------------------------------------
+
+private val previewData = NodePreviewParameterProvider()
+
+// ---------------------------------------------------------------------------
+// DeviceActions previews
+// ---------------------------------------------------------------------------
+
+@PreviewLightDark
+@Composable
+private fun DeviceActionsRemotePreview() {
+ val node = previewData.mickeyMouse
+ AppTheme {
+ Surface {
+ DeviceActions(
+ node = node,
+ ourNode = previewData.mickeyMouse.copy(num = 9999),
+ lastTracerouteTime = null,
+ lastRequestNeighborsTime = null,
+ availableLogs =
+ setOf(
+ LogsType.DEVICE,
+ LogsType.POSITIONS,
+ LogsType.ENVIRONMENT,
+ LogsType.SIGNAL,
+ LogsType.TRACEROUTE,
+ ),
+ onAction = {},
+ displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
+ isFahrenheit = false,
+ )
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun DeviceActionsLocalPreview() {
+ val node = previewData.mickeyMouse
+ AppTheme {
+ Surface {
+ DeviceActions(
+ node = node,
+ ourNode = node,
+ lastTracerouteTime = null,
+ lastRequestNeighborsTime = null,
+ availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS),
+ onAction = {},
+ displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
+ isFahrenheit = false,
+ isLocal = true,
+ )
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// TelemetricActionsSection previews
+// ---------------------------------------------------------------------------
+
+@PreviewLightDark
+@Composable
+private fun TelemetricActionsSectionPreview() {
+ val node = previewData.mickeyMouse
+ AppTheme {
+ Surface {
+ TelemetricActionsSection(
+ node = node,
+ ourNode = previewData.mickeyMouse.copy(num = 9999),
+ availableLogs =
+ setOf(
+ LogsType.DEVICE,
+ LogsType.POSITIONS,
+ LogsType.ENVIRONMENT,
+ LogsType.SIGNAL,
+ LogsType.TRACEROUTE,
+ LogsType.NEIGHBOR_INFO,
+ ),
+ lastTracerouteTime = null,
+ lastRequestNeighborsTime = null,
+ displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
+ isFahrenheit = false,
+ onAction = {},
+ )
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun TelemetricActionsSectionEmptyPreview() {
+ val node = previewData.minnieMouse
+ AppTheme {
+ Surface {
+ TelemetricActionsSection(
+ node = node,
+ ourNode = previewData.mickeyMouse,
+ availableLogs = emptySet(),
+ lastTracerouteTime = null,
+ lastRequestNeighborsTime = null,
+ displayUnits = Config.DisplayConfig.DisplayUnits.IMPERIAL,
+ isFahrenheit = true,
+ onAction = {},
+ )
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// PositionInlineContent preview
+// ---------------------------------------------------------------------------
+
+@PreviewLightDark
+@Composable
+private fun PositionInlineContentPreview() {
+ val node = previewData.mickeyMouse
+ AppTheme {
+ Surface {
+ PositionInlineContent(
+ node = node,
+ ourNode = previewData.mickeyMouse.copy(num = 9999),
+ displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
+ onAction = {},
+ )
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// NodeDetailsSection preview
+// ---------------------------------------------------------------------------
+
+@PreviewLightDark
+@Composable
+private fun NodeDetailsSectionPreview() {
+ val node = previewData.mickeyMouse
+ AppTheme { Surface { NodeDetailsSection(node = node) } }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index c687c620e0..0ab017f7b6 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -16,10 +16,7 @@
*/
package org.meshtastic.feature.node.component
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,9 +25,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.material3.AssistChip
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -42,87 +36,48 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
-import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.open_compass
-import org.meshtastic.core.resources.position
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
-import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.LocalInlineMapProvider
-import org.meshtastic.feature.node.model.LogsType
-import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.Config
-private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
-private const val COMPASS_BUTTON_WEIGHT = 0.9f
private const val MAP_HEIGHT_DP = 200
/**
- * Displays node position details, last update time, distance, and related actions like requesting position and
- * accessing map/position logs.
+ * Inline position content shown beneath the Position row in the Telemetry section. Displays the inline map with
+ * distance badge, linked coordinates, and compass button.
*/
@Composable
-fun PositionSection(
+internal fun PositionInlineContent(
node: Node,
ourNode: Node?,
- metricsState: MetricsState,
- availableLogs: Set<LogsType>,
+ displayUnits: Config.DisplayConfig.DisplayUnits,
onAction: (NodeDetailAction) -> Unit,
- modifier: Modifier = Modifier,
) {
- val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
- val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
- val isLocal = metricsState.isLocal
-
- SectionCard(title = Res.string.position, modifier = modifier) {
- Column(modifier = Modifier.padding(horizontal = 16.dp)) {
- if (hasValidPosition) {
- PositionMap(node, distance)
- LinkedCoordinatesItem(node, metricsState.displayUnits)
- Spacer(Modifier.height(8.dp))
- }
-
- PositionActionButtons(
- node = node,
- isLocal = isLocal,
- hasValidPosition = hasValidPosition,
- displayUnits = metricsState.displayUnits,
- onAction = onAction,
- )
+ val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(displayUnits)
- if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) {
- Spacer(Modifier.height(12.dp))
- FlowRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- if (availableLogs.contains(LogsType.NODE_MAP)) {
- AssistChip(
- onClick = { onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num))) },
- label = { Text(stringResource(LogsType.NODE_MAP.titleRes)) },
- leadingIcon = { Icon(vectorResource(LogsType.NODE_MAP.icon), null, Modifier.size(18.dp)) },
- )
- }
-
- if (availableLogs.contains(LogsType.POSITIONS)) {
- AssistChip(
- onClick = {
- onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
- },
- label = { Text(stringResource(LogsType.POSITIONS.titleRes)) },
- leadingIcon = { Icon(vectorResource(LogsType.POSITIONS.icon), null, Modifier.size(18.dp)) },
- )
- }
- }
- }
- }
+ PositionMap(node, distance)
+ LinkedCoordinatesItem(node, displayUnits)
+ Spacer(Modifier.height(8.dp))
+ FilledTonalButton(
+ onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = stringResource(Res.string.open_compass),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
}
@@ -150,59 +105,3 @@ private fun PositionMap(node: Node, distance: String?) {
}
}
}
-
-@Composable
-private fun PositionActionButtons(
- node: Node,
- isLocal: Boolean,
- hasValidPosition: Boolean,
- displayUnits: Config.DisplayConfig.DisplayUnits,
- onAction: (NodeDetailAction) -> Unit,
-) {
- if (isLocal && !hasValidPosition) return
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- if (!isLocal) {
- Button(
- onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
- modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT),
- shape = MaterialTheme.shapes.large,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
- ),
- ) {
- Icon(MeshtasticIcons.LocationOn, null, Modifier.size(18.dp))
- Spacer(Modifier.width(6.dp))
- Text(
- text = stringResource(Res.string.exchange_position),
- style = MaterialTheme.typography.labelLarge,
- maxLines = 1,
- overflow = TextOverflow.Visible,
- )
- }
- }
-
- if (hasValidPosition) {
- FilledTonalButton(
- onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
- modifier = if (isLocal) Modifier.fillMaxWidth() else Modifier.weight(COMPASS_BUTTON_WEIGHT),
- shape = MaterialTheme.shapes.large,
- ) {
- Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp))
- Spacer(Modifier.width(6.dp))
- Text(
- text = stringResource(Res.string.open_compass),
- style = MaterialTheme.typography.labelLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
- }
-}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
index f3825817d9..22588aebd0 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
@@ -61,8 +61,8 @@ import org.meshtastic.core.resources.userinfo
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.feature.node.model.LogsType
-import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
+import org.meshtastic.proto.Config
private data class TelemetricFeature(
val titleRes: StringResource,
@@ -72,21 +72,32 @@ private data class TelemetricFeature(
val isVisible: (Node) -> Boolean = { true },
val cooldownTimestamp: Long? = null,
val cooldownDuration: Long = COOL_DOWN_TIME_MS,
- val content: @Composable ((Node) -> Unit)? = null,
+ val content: @Composable ((Node, (NodeDetailAction) -> Unit) -> Unit)? = null,
val hasContent: (Node) -> Boolean = { false },
)
@Composable
internal fun TelemetricActionsSection(
node: Node,
+ ourNode: Node?,
availableLogs: Set<LogsType>,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
- metricsState: MetricsState,
+ displayUnits: Config.DisplayConfig.DisplayUnits,
+ isFahrenheit: Boolean,
onAction: (NodeDetailAction) -> Unit,
isLocal: Boolean = false,
) {
- val features = rememberTelemetricFeatures(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal)
+ val features =
+ rememberTelemetricFeatures(
+ node,
+ ourNode,
+ lastTracerouteTime,
+ lastRequestNeighborsTime,
+ displayUnits,
+ isFahrenheit,
+ isLocal,
+ )
SectionCard(title = Res.string.telemetry) {
features
@@ -111,83 +122,94 @@ internal fun TelemetricActionsSection(
@Composable
private fun rememberTelemetricFeatures(
node: Node,
+ ourNode: Node?,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
- metricsState: MetricsState,
+ displayUnits: Config.DisplayConfig.DisplayUnits,
+ isFahrenheit: Boolean,
isLocal: Boolean,
-): List<TelemetricFeature> = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal) {
- listOf(
- TelemetricFeature(
- titleRes = Res.string.userinfo,
- icon = Res.drawable.ic_person,
- requestAction = { NodeMenuAction.RequestUserInfo(it) },
- isVisible = { !isLocal },
- ),
- TelemetricFeature(
- titleRes = LogsType.TRACEROUTE.titleRes,
- icon = LogsType.TRACEROUTE.icon,
- requestAction = { NodeMenuAction.TraceRoute(it) },
- logsType = LogsType.TRACEROUTE,
- cooldownTimestamp = lastTracerouteTime,
- isVisible = { !isLocal },
- ),
- TelemetricFeature(
- titleRes = LogsType.NEIGHBOR_INFO.titleRes,
- icon = LogsType.NEIGHBOR_INFO.icon,
- requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
- logsType = LogsType.NEIGHBOR_INFO,
- isVisible = { it.capabilities.canRequestNeighborInfo },
- cooldownTimestamp = lastRequestNeighborsTime,
- cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
- ),
- TelemetricFeature(
- titleRes = LogsType.SIGNAL.titleRes,
- icon = LogsType.SIGNAL.icon,
- requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
- logsType = LogsType.SIGNAL,
- isVisible = { !isLocal },
- ),
- TelemetricFeature(
- titleRes = LogsType.DEVICE.titleRes,
- icon = LogsType.DEVICE.icon,
- requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) },
- logsType = LogsType.DEVICE,
- ),
- TelemetricFeature(
- titleRes = LogsType.ENVIRONMENT.titleRes,
- icon = Res.drawable.ic_thermostat,
- requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) },
- logsType = LogsType.ENVIRONMENT,
- content = { EnvironmentMetrics(it, metricsState.displayUnits, metricsState.isFahrenheit) },
- hasContent = { it.hasEnvironmentMetrics },
- ),
- TelemetricFeature(
- titleRes = Res.string.request_air_quality_metrics,
- icon = Res.drawable.ic_air,
- requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) },
- ),
- TelemetricFeature(
- titleRes = LogsType.POWER.titleRes,
- icon = LogsType.POWER.icon,
- requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) },
- logsType = LogsType.POWER,
- content = { PowerMetrics(it) },
- hasContent = { it.hasPowerMetrics },
- ),
- TelemetricFeature(
- titleRes = LogsType.HOST.titleRes,
- icon = LogsType.HOST.icon,
- requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) },
- logsType = LogsType.HOST,
- ),
- TelemetricFeature(
- titleRes = LogsType.PAX.titleRes,
- icon = LogsType.PAX.icon,
- requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) },
- logsType = LogsType.PAX,
- ),
- )
-}
+): List<TelemetricFeature> =
+ remember(node, ourNode, lastTracerouteTime, lastRequestNeighborsTime, displayUnits, isFahrenheit, isLocal) {
+ listOf(
+ TelemetricFeature(
+ titleRes = Res.string.userinfo,
+ icon = Res.drawable.ic_person,
+ requestAction = { NodeMenuAction.RequestUserInfo(it) },
+ isVisible = { !isLocal },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.POSITIONS.titleRes,
+ icon = LogsType.POSITIONS.icon,
+ requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) },
+ logsType = LogsType.POSITIONS,
+ content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) },
+ hasContent = { it.latitude != 0.0 || it.longitude != 0.0 },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.TRACEROUTE.titleRes,
+ icon = LogsType.TRACEROUTE.icon,
+ requestAction = { NodeMenuAction.TraceRoute(it) },
+ logsType = LogsType.TRACEROUTE,
+ cooldownTimestamp = lastTracerouteTime,
+ isVisible = { !isLocal },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.NEIGHBOR_INFO.titleRes,
+ icon = LogsType.NEIGHBOR_INFO.icon,
+ requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
+ logsType = LogsType.NEIGHBOR_INFO,
+ isVisible = { it.capabilities.canRequestNeighborInfo },
+ cooldownTimestamp = lastRequestNeighborsTime,
+ cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.SIGNAL.titleRes,
+ icon = LogsType.SIGNAL.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
+ logsType = LogsType.SIGNAL,
+ isVisible = { !isLocal },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.DEVICE.titleRes,
+ icon = LogsType.DEVICE.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) },
+ logsType = LogsType.DEVICE,
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.ENVIRONMENT.titleRes,
+ icon = Res.drawable.ic_thermostat,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) },
+ logsType = LogsType.ENVIRONMENT,
+ content = { node, _ -> EnvironmentMetrics(node, displayUnits, isFahrenheit) },
+ hasContent = { it.hasEnvironmentMetrics },
+ ),
+ TelemetricFeature(
+ titleRes = Res.string.request_air_quality_metrics,
+ icon = Res.drawable.ic_air,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.POWER.titleRes,
+ icon = LogsType.POWER.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) },
+ logsType = LogsType.POWER,
+ content = { node, _ -> PowerMetrics(node) },
+ hasContent = { it.hasPowerMetrics },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.HOST.titleRes,
+ icon = LogsType.HOST.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) },
+ logsType = LogsType.HOST,
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.PAX.titleRes,
+ icon = LogsType.PAX.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) },
+ logsType = LogsType.PAX,
+ ),
+ )
+ }
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@@ -273,7 +295,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
if (showContent) {
Column(modifier = Modifier.padding(start = 56.dp, end = 20.dp, bottom = 12.dp)) {
- feature.content.invoke(node)
+ feature.content.invoke(node, onAction)
}
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
index e0d8fe1d11..03367debfe 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
@@ -41,7 +41,6 @@ import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NotesSection
-import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.NodeDetailAction
/**
@@ -81,8 +80,8 @@ fun NodeDetailContent(
}
/**
- * Scrollable list of node detail sections: identity, device actions, position, hardware details, notes, and
- * administration.
+ * Scrollable list of node detail sections: identity, device actions (including telemetry and position), hardware
+ * details, notes, and administration.
*/
@Composable
fun NodeDetailList(
@@ -105,15 +104,16 @@ fun NodeDetailList(
item {
DeviceActions(
node = node,
+ ourNode = ourNode,
lastTracerouteTime = uiState.lastTracerouteTime,
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
availableLogs = uiState.availableLogs,
onAction = onAction,
- metricsState = uiState.metricsState,
+ displayUnits = uiState.metricsState.displayUnits,
+ isFahrenheit = uiState.metricsState.isFahrenheit,
isLocal = uiState.metricsState.isLocal,
)
}
- item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) }
if (uiState.metricsState.deviceHardware != null) {
item { DeviceDetailsSection(uiState.metricsState) }
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt
new file mode 100644
index 0000000000..caa68b1067
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+@file:Suppress("TooManyFunctions", "MagicNumber")
+
+package org.meshtastic.feature.node.detail
+
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.feature.node.model.LogsType
+import org.meshtastic.feature.node.model.MetricsState
+
+// ---------------------------------------------------------------------------
+// Sample data for previews
+// ---------------------------------------------------------------------------
+
+private val previewData = NodePreviewParameterProvider()
+
+// ---------------------------------------------------------------------------
+// NodeDetailContent previews
+// ---------------------------------------------------------------------------
+
+@PreviewLightDark
+@Composable
+private fun NodeDetailContentRemotePreview() {
+ val node = previewData.mickeyMouse
+ AppTheme {
+ Surface {
+ NodeDetailContent(
+ uiState =
+ NodeDetailUiState(
+ node = node,
+ ourNode = previewData.mickeyMouse.copy(num = 9999),
+ metricsState = MetricsState(isLocal = false, isManaged = false),
+ availableLogs =
+ setOf(
+ LogsType.DEVICE,
+ LogsType.POSITIONS,
+ LogsType.ENVIRONMENT,
+ LogsType.SIGNAL,
+ LogsType.TRACEROUTE,
+ ),
+ ),
+ onAction = {},
+ onFirmwareSelect = {},
+ onSaveNotes = { _, _ -> },
+ )
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun NodeDetailContentLocalPreview() {
+ val node = previewData.mickeyMouse
+ AppTheme {
+ Surface {
+ NodeDetailContent(
+ uiState =
+ NodeDetailUiState(
+ node = node,
+ ourNode = node,
+ metricsState = MetricsState(isLocal = true, isManaged = false),
+ availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS),
+ ),
+ onAction = {},
+ onFirmwareSelect = {},
+ onSaveNotes = { _, _ -> },
+ )
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun NodeDetailContentLoadingPreview() {
+ AppTheme {
+ Surface {
+ NodeDetailContent(
+ uiState = NodeDetailUiState(),
+ onAction = {},
+ onFirmwareSelect = {},
+ onSaveNotes = { _, _ -> },
+ )
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun NodeDetailContentMinimalPreview() {
+ val node = previewData.minnieMouse
+ AppTheme {
+ Surface {
+ NodeDetailContent(
+ uiState =
+ NodeDetailUiState(
+ node = node,
+ ourNode = previewData.mickeyMouse,
+ metricsState = MetricsState(isLocal = false, isManaged = true),
+ availableLogs = emptySet(),
+ ),
+ onAction = {},
+ onFirmwareSelect = {},
+ onSaveNotes = { _, _ -> },
+ )
+ }
+ }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
index 661010debb..a7b33f6a7a 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
@@ -186,7 +186,6 @@ constructor(
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
- add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
index cf3fd8d3aa..0b9f400446 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
@@ -19,9 +19,9 @@ package org.meshtastic.feature.node.metrics
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@@ -31,16 +31,13 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
@@ -65,16 +62,12 @@ import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.avg
import org.meshtastic.core.resources.collapse_chart
import org.meshtastic.core.resources.expand_chart
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.logs
-import org.meshtastic.core.resources.max
-import org.meshtastic.core.resources.min
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.BarChart
import org.meshtastic.core.ui.icon.Info
@@ -137,6 +130,46 @@ fun GenericMetricChart(
)
}
+/**
+ * Common scaffold for all metric chart composables. Provides:
+ * - A [Column] container with the supplied [modifier]
+ * - An empty-data guard (returns early when [isEmpty] is true)
+ * - A remembered [CartesianChartModelProducer] passed to [content]
+ * - A trailing [Legend] strip
+ *
+ * @param isEmpty Whether the chart data is empty — when true, nothing is rendered.
+ * @param legendData Legend items shown below the chart.
+ * @param key Optional key for the [CartesianChartModelProducer] (e.g. a selected channel). Pass a different value to
+ * recreate the producer.
+ * @param hiddenSet Indices of hidden legend items (toggleable legend).
+ * @param onToggle Callback when a legend item is toggled; when null, a read-only legend is rendered.
+ * @param content Builder lambda receiving the [CartesianChartModelProducer] and a standard `Modifier.weight(1f)`
+ * suitable for the chart area.
+ */
+@Composable
+fun MetricChartScaffold(
+ isEmpty: Boolean,
+ legendData: List<LegendData>,
+ modifier: Modifier = Modifier,
+ key: Any? = Unit,
+ hiddenSet: Set<Int> = emptySet(),
+ onToggle: ((Int) -> Unit)? = null,
+ content: @Composable ColumnScope.(CartesianChartModelProducer, Modifier) -> Unit,
+) {
+ Column(modifier = modifier) {
+ if (isEmpty) return@Column
+ val modelProducer = remember(key) { CartesianChartModelProducer() }
+ val chartModifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp)
+ content(modelProducer, chartModifier)
+ Legend(
+ legendData = legendData,
+ modifier = Modifier.padding(top = 0.dp),
+ hiddenSet = hiddenSet,
+ onToggle = onToggle,
+ )
+ }
+}
+
/**
* An adaptive layout for metric screens. Uses a split Row for wide screens (tablets/landscape) and a stacked Column for
* narrow screens (phones). When [isChartExpanded] is true, the card list is hidden and the chart fills the available
@@ -164,7 +197,7 @@ fun AdaptiveMetricLayout(
if (isChartExpanded) {
Modifier.fillMaxWidth().weight(1f)
} else {
- Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)
+ Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.45f)
},
)
AnimatedVisibility(visible = !isChartExpanded, enter = expandVertically(), exit = shrinkVertically()) {
@@ -175,40 +208,6 @@ fun AdaptiveMetricLayout(
}
}
-/**
- * Displays a compact row of min/max/avg statistics for a metric. Intended to be placed between the chart controls and
- * the chart itself.
- */
-@Composable
-fun MetricSummaryRow(values: List<Float>, label: String = "", modifier: Modifier = Modifier) {
- if (values.isEmpty()) return
- val minVal = values.min()
- val maxVal = values.max()
- val avgVal = values.average().toFloat()
-
- Row(
- modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- SummaryChip(label = stringResource(Res.string.min), value = formatString("%.1f %s", minVal, label))
- SummaryChip(label = stringResource(Res.string.avg), value = formatString("%.1f %s", avgVal, label))
- SummaryChip(label = stringResource(Res.string.max), value = formatString("%.1f %s", maxVal, label))
- }
-}
-
-@Composable
-private fun SummaryChip(label: String, value: String) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text(
- text = label,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(text = value, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
- }
-}
-
/**
* A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and chart-to-list
* synchronisation.

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt
index 81709c6fd8..c1cf0e04e5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt
@@ -29,10 +29,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
+import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.decoration.Decoration
import com.patrykandpatrick.vico.compose.cartesian.decoration.HorizontalLine
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine
+import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.LineCartesianLayerMarkerTarget
@@ -249,10 +252,13 @@ object ChartStyling {
if (target is LineCartesianLayerMarkerTarget) {
target.points.forEachIndexed { pointIndex, point ->
if (pointIndex > 0) append(", ")
- // Force alpha to 1f so text is readable even if the line is transparent/subtle
- val color = point.color.copy(alpha = .8f)
- val text = format(point.entry.y, color)
- withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) { append(text) }
+ // Pass the opaque color to the format lambda so callers can match without alpha gymnastics.
+ // Apply 0.8 alpha only on the rendered text for readability.
+ val opaqueColor = point.color.copy(alpha = 1f)
+ val text = format(point.entry.y, opaqueColor)
+ withStyle(SpanStyle(color = opaqueColor.copy(alpha = .8f), fontWeight = FontWeight.Bold)) {
+ append(text)
+ }
}
}
}
@@ -267,3 +273,25 @@ object ChartStyling {
fun rememberAxisLabel(color: Color = MaterialTheme.colorScheme.onSurfaceVariant): TextComponent =
rememberTextComponent(style = TextStyle(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium))
}
+
+/**
+ * Creates a [LineCartesianLayer] only when [hasData] is true, returning null otherwise.
+ *
+ * Extracts the repeated `if (data.isNotEmpty()) rememberLineCartesianLayer(...) else null` pattern used in every metric
+ * chart composable.
+ */
+@Composable
+fun rememberConditionalLayer(
+ hasData: Boolean,
+ lineProvider: LineCartesianLayer.LineProvider,
+ verticalAxisPosition: Axis.Position.Vertical,
+ rangeProvider: CartesianLayerRangeProvider? = null,
+): LineCartesianLayer? = if (hasData) {
+ rememberLineCartesianLayer(
+ lineProvider = lineProvider,
+ verticalAxisPosition = verticalAxisPosition,
+ rangeProvider = rangeProvider ?: CartesianLayerRangeProvider.auto(),
+ )
+} else {
+ null
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
index 628c7e2e8a..bb6efdff69 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
@@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
@@ -56,6 +57,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.util.TimeConstants
+import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.info
@@ -63,29 +65,13 @@ import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.snr
import org.meshtastic.core.ui.icon.Info
import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.theme.AppTheme
import kotlin.time.Duration.Companion.days
object CommonCharts {
- const val MS_PER_SEC = 1000L
const val MAX_PERCENT_VALUE = 100f
const val SCROLL_BIAS = 0.5f
- /** Gets the Material 3 primary color with optional opacity adjustment. */
- @Composable
- fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha)
-
- /** Gets the Material 3 secondary color with optional opacity adjustment. */
- @Composable
- fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha)
-
- /** Gets the Material 3 tertiary color with optional opacity adjustment. */
- @Composable
- fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha)
-
- /** Gets the Material 3 error color with optional opacity adjustment. */
- @Composable
- fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha)
-
/**
* A dynamic [CartesianValueFormatter] that adjusts the time format based on the total data span
* ([CartesianRanges.xLength]).
@@ -118,8 +104,6 @@ object CommonCharts {
}
}
- fun formatDateTime(timestampMillis: Long): String = DateFormatter.formatDateTime(timestampMillis)
-
/**
* Shared bottom time axis used by all metric chart screens.
*
@@ -142,7 +126,7 @@ data class LegendData(
val nameRes: StringResource,
val color: Color,
val isLine: Boolean = false,
- val environmentMetric: Environment? = null,
+ val metricKey: Any? = null,
)
data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color)
@@ -163,9 +147,9 @@ fun Legend(
onToggle: ((Int) -> Unit)? = null,
) {
FlowRow(
- modifier = modifier.fillMaxWidth(),
+ modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
- verticalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
) {
legendData.forEachIndexed { index, data ->
val isVisible = index !in hiddenSet
@@ -173,7 +157,7 @@ fun Legend(
FilterChip(
selected = isVisible,
onClick = { onToggle(index) },
- label = { Text(stringResource(data.nameRes)) },
+ label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) },
leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) },
modifier = Modifier.padding(horizontal = 2.dp),
)
@@ -262,7 +246,8 @@ fun MetricIndicator(color: Color, modifier: Modifier = Modifier) {
Box(modifier = modifier.size(8.dp).clip(CircleShape).background(color))
}
-@Suppress("UnusedPrivateMember") // Compose preview
+@PreviewLightDark
+@Suppress("unused") // Compose preview
@Composable
private fun LegendPreview() {
val data =
@@ -270,10 +255,12 @@ private fun LegendPreview() {
LegendData(nameRes = Res.string.rssi, color = Color.Red, isLine = true),
LegendData(nameRes = Res.string.snr, color = Color.Green, isLine = true),
)
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- // Read-only legend
- Legend(legendData = data)
- // Toggleable legend
- Legend(legendData = data, hiddenSet = setOf(1), onToggle = {})
+ AppTheme {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ // Read-only legend
+ Legend(legendData = data)
+ // Toggleable legend
+ Legend(legendData = data, hiddenSet = setOf(1), onToggle = {})
+ }
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
index 73b415035a..a3fef636f6 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
@@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -32,9 +31,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -49,21 +45,22 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
-import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
-import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_util_definition
@@ -84,7 +81,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Cyan
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Purple
-import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
private enum class Device(val color: Color) {
@@ -106,20 +102,10 @@ private enum class Device(val color: Color) {
private val LEGEND_DATA =
listOf(
- LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null),
- LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true, environmentMetric = null),
- LegendData(
- nameRes = Res.string.channel_utilization,
- color = Device.CH_UTIL.color,
- isLine = true,
- environmentMetric = null,
- ),
- LegendData(
- nameRes = Res.string.air_utilization,
- color = Device.AIR_UTIL.color,
- isLine = true,
- environmentMetric = null,
- ),
+ LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true),
+ LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true),
+ LegendData(nameRes = Res.string.channel_utilization, color = Device.CH_UTIL.color, isLine = true),
+ LegendData(nameRes = Res.string.air_utilization, color = Device.AIR_UTIL.color, isLine = true),
)
@Suppress("LongMethod")
@@ -188,10 +174,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
- if (hasBattery) {
- val batteryValues = remember(data) { data.mapNotNull { it.device_metrics?.battery_level?.toFloat() } }
- MetricSummaryRow(values = batteryValues, label = "%")
- }
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
DeviceMetricsChart(
@@ -219,7 +201,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
@@ -228,10 +209,10 @@ private fun DeviceMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- Column(modifier = modifier) {
- if (telemetries.isEmpty()) return@Column
-
- val modelProducer = remember { CartesianChartModelProducer() }
+ MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = legendData, modifier = modifier) {
+ modelProducer,
+ chartModifier,
+ ->
val batteryColor = Device.BATTERY.color
val voltageColor = Device.VOLTAGE.color
val chUtilColor = Device.CH_UTIL.color
@@ -247,7 +228,7 @@ private fun DeviceMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
- when (color.copy(alpha = 1f)) {
+ when (color) {
batteryColor -> formatString(percentValueTemplate, batteryLabel, value)
voltageColor -> formatString(voltageValueTemplate, voltageLabel, value)
chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value)
@@ -322,28 +303,20 @@ private fun DeviceMetricsChart(
}
val leftLayer =
- if (leftLayerSeriesStyles.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
- verticalAxisPosition = Axis.Position.Vertical.Start,
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0),
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = leftLayerSeriesStyles.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0),
+ )
val rightLayer =
- if (voltageData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider =
- LineCartesianLayer.LineProvider.series(
- ChartStyling.createGradientLine(lineColor = voltageColor),
- ),
- verticalAxisPosition = Axis.Position.Vertical.End,
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = voltageData.isNotEmpty(),
+ lineProvider =
+ LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(lineColor = voltageColor)),
+ verticalAxisPosition = Axis.Position.Vertical.End,
+ )
val layers = remember(leftLayer, rightLayer) { listOfNotNull(leftLayer, rightLayer) }
@@ -356,7 +329,7 @@ private fun DeviceMetricsChart(
GenericMetricChart(
modelProducer = modelProducer,
- modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
+ modifier = chartModifier,
layers = layers,
startAxis =
if (leftLayer != null) {
@@ -384,14 +357,12 @@ private fun DeviceMetricsChart(
vicoScrollState = vicoScrollState,
)
}
-
- Legend(legendData = legendData, modifier = Modifier.padding(top = 0.dp))
}
}
-@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
+@PreviewLightDark
+@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsChartPreview() {
val now = nowSeconds.toInt()
val telemetries =
@@ -422,7 +393,6 @@ private fun DeviceMetricsChartPreview() {
@Composable
@Suppress("LongMethod")
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val deviceMetrics = telemetry.device_metrics
val time = telemetry.time.toLong() * MS_PER_SEC
@@ -431,101 +401,75 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
val uptimeLabel = stringResource(Res.string.uptime)
val percentValueTemplate = stringResource(Res.string.device_metrics_percent_value)
val labelValueTemplate = stringResource(Res.string.device_metrics_label_value)
- Card(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
- border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
- colors =
- CardDefaults.cardColors(
- containerColor =
- if (isSelected) {
- MaterialTheme.colorScheme.primaryContainer
- } else {
- MaterialTheme.colorScheme.surfaceVariant
- },
- ),
- ) {
- Surface(color = Color.Transparent) {
- SelectionContainer {
- Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
- /* Time, Battery, and Voltage */
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Text(
- text = CommonCharts.formatDateTime(time),
- style = MaterialTheme.typography.titleMediumEmphasized,
- fontWeight = FontWeight.Bold,
- )
+ SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
+ Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
+ /* Time, Battery, and Voltage */
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ text = DateFormatter.formatDateTime(time),
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ fontWeight = FontWeight.Bold,
+ )
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (deviceMetrics?.battery_level != null) {
- MetricIndicator(Device.BATTERY.color)
- Spacer(Modifier.width(4.dp))
- }
- if (deviceMetrics?.voltage != null) {
- MetricIndicator(Device.VOLTAGE.color)
- Spacer(Modifier.width(8.dp))
- }
- MaterialBatteryInfo(
- level = deviceMetrics?.battery_level ?: 0,
- voltage = deviceMetrics?.voltage ?: 0f,
- )
- }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (deviceMetrics?.battery_level != null) {
+ MetricIndicator(Device.BATTERY.color)
+ Spacer(Modifier.width(4.dp))
+ }
+ if (deviceMetrics?.voltage != null) {
+ MetricIndicator(Device.VOLTAGE.color)
+ Spacer(Modifier.width(8.dp))
}
+ MaterialBatteryInfo(
+ level = deviceMetrics?.battery_level ?: 0,
+ voltage = deviceMetrics?.voltage ?: 0f,
+ )
+ }
+ }
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(8.dp))
- /* Channel Utilization and Air Utilization Tx */
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (deviceMetrics?.channel_utilization != null) {
- MetricIndicator(Device.CH_UTIL.color)
- Spacer(Modifier.width(4.dp))
- Text(
- text =
- formatString(
- percentValueTemplate,
- channelUtilizationLabel,
- deviceMetrics.channel_utilization ?: 0f,
- ),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
- Spacer(Modifier.width(12.dp))
- }
- if (deviceMetrics?.air_util_tx != null) {
- MetricIndicator(Device.AIR_UTIL.color)
- Spacer(Modifier.width(4.dp))
- Text(
- text =
- formatString(
- percentValueTemplate,
- airUtilizationLabel,
- deviceMetrics.air_util_tx ?: 0f,
- ),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
- }
- }
- Text(
+ /* Channel Utilization and Air Utilization Tx */
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (deviceMetrics?.channel_utilization != null) {
+ MetricValueRow(
+ color = Device.CH_UTIL.color,
text =
formatString(
- labelValueTemplate,
- uptimeLabel,
- formatUptime(deviceMetrics?.uptime_seconds ?: 0),
+ percentValueTemplate,
+ channelUtilizationLabel,
+ deviceMetrics.channel_utilization ?: 0f,
+ ),
+ )
+ Spacer(Modifier.width(12.dp))
+ }
+ if (deviceMetrics?.air_util_tx != null) {
+ MetricValueRow(
+ color = Device.AIR_UTIL.color,
+ text =
+ formatString(
+ percentValueTemplate,
+ airUtilizationLabel,
+ deviceMetrics.air_util_tx ?: 0f,
),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
+ Text(
+ text =
+ formatString(labelValueTemplate, uptimeLabel, formatUptime(deviceMetrics?.uptime_seconds ?: 0)),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
}
}
}
}
-@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
+@PreviewLightDark
+@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsCardPreview() {
val now = nowSeconds.toInt()
val telemetry =
@@ -543,9 +487,9 @@ private fun DeviceMetricsCardPreview() {
AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) }
}
-@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
+@PreviewLightDark
+@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsScreenPreview() {
val now = nowSeconds.toInt()
val telemetries =

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
index cd8a4ab3f9..c0164dd802 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
@@ -57,52 +57,42 @@ private val LEGEND_DATA_1 =
nameRes = Res.string.temperature,
color = Environment.TEMPERATURE.color,
isLine = true,
- environmentMetric = Environment.TEMPERATURE,
+ metricKey = Environment.TEMPERATURE,
),
LegendData(
nameRes = Res.string.humidity,
color = Environment.HUMIDITY.color,
isLine = true,
- environmentMetric = Environment.HUMIDITY,
+ metricKey = Environment.HUMIDITY,
),
)
private val LEGEND_DATA_2 =
listOf(
- LegendData(
- nameRes = Res.string.iaq,
- color = Environment.IAQ.color,
- isLine = true,
- environmentMetric = Environment.IAQ,
- ),
+ LegendData(nameRes = Res.string.iaq, color = Environment.IAQ.color, isLine = true, metricKey = Environment.IAQ),
LegendData(
nameRes = Res.string.baro_pressure,
color = Environment.BAROMETRIC_PRESSURE.color,
isLine = true,
- environmentMetric = Environment.BAROMETRIC_PRESSURE,
- ),
- LegendData(
- nameRes = Res.string.lux,
- color = Environment.LUX.color,
- isLine = true,
- environmentMetric = Environment.LUX,
+ metricKey = Environment.BAROMETRIC_PRESSURE,
),
+ LegendData(nameRes = Res.string.lux, color = Environment.LUX.color, isLine = true, metricKey = Environment.LUX),
LegendData(
nameRes = Res.string.uv_lux,
color = Environment.UV_LUX.color,
isLine = true,
- environmentMetric = Environment.UV_LUX,
+ metricKey = Environment.UV_LUX,
),
LegendData(
nameRes = Res.string.wind_speed,
color = Environment.WIND_SPEED.color,
isLine = true,
- environmentMetric = Environment.WIND_SPEED,
+ metricKey = Environment.WIND_SPEED,
),
LegendData(
nameRes = Res.string.radiation,
color = Environment.RADIATION.color,
isLine = true,
- environmentMetric = Environment.RADIATION,
+ metricKey = Environment.RADIATION,
),
)
@@ -112,13 +102,13 @@ private val LEGEND_DATA_3 =
nameRes = Res.string.soil_temperature,
color = Environment.SOIL_TEMPERATURE.color,
isLine = true,
- environmentMetric = Environment.SOIL_TEMPERATURE,
+ metricKey = Environment.SOIL_TEMPERATURE,
),
LegendData(
nameRes = Res.string.soil_moisture,
color = Environment.SOIL_MOISTURE.color,
isLine = true,
- environmentMetric = Environment.SOIL_MOISTURE,
+ metricKey = Environment.SOIL_MOISTURE,
),
)
@@ -143,14 +133,14 @@ fun EnvironmentMetricsChart(
val allLegendData =
(LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter {
- graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0]
+ graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0]
}
// Legend toggle state: tracks indices into allLegendData that are hidden
var hiddenIndices by remember { mutableStateOf(emptySet<Int>()) }
val hiddenMetrics =
remember(hiddenIndices, allLegendData) {
- hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.environmentMetric }.toSet()
+ hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet()
}
val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) }
@@ -216,7 +206,7 @@ fun EnvironmentMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
- val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
+ val label = colorToLabel[color] ?: ""
formatString("%s: %.1f", label, value)
},
)

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
index ee830a08e5..2b47fd5e1c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
@@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -31,26 +30,24 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.env_metrics_log
@@ -73,7 +70,7 @@ import org.meshtastic.core.resources.wind_lull
import org.meshtastic.core.resources.wind_speed
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
-import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
+import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Telemetry
@Composable
@@ -100,14 +97,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
- val tempValues =
- remember(filteredTelemetries) {
- filteredTelemetries.mapNotNull { it.environment_metrics?.temperature?.takeIf { t -> !t.isNaN() } }
- }
- if (tempValues.isNotEmpty()) {
- val unit = if (state.isFahrenheit) "°F" else "°C"
- MetricSummaryRow(values = tempValues, label = unit)
- }
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
EnvironmentMetricsChart(
@@ -135,7 +124,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun TemperatureDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
@@ -157,7 +145,6 @@ private fun TemperatureDisplay(
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasHumidity = envMetrics.relative_humidity?.let { !it.isNaN() } == true
val hasPressure = envMetrics.barometric_pressure?.let { !it.isNaN() && it > 0 } == true
@@ -198,7 +185,6 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SoilMetricsDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
@@ -251,7 +237,6 @@ private fun SoilMetricsDisplay(
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasLux = envMetrics.lux != null && !envMetrics.lux!!.isNaN()
val hasUvLux = envMetrics.uv_lux != null && !envMetrics.uv_lux!!.isNaN()
@@ -287,7 +272,6 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage!!.isNaN()
val hasCurrent = envMetrics.current != null && !envMetrics.current!!.isNaN()
@@ -315,7 +299,6 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val iaqValue = envMetrics.iaq
val gasResistance = envMetrics.gas_resistance
@@ -351,7 +334,6 @@ private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
envMetrics.radiation?.let { radiation ->
if (!radiation.isNaN() && radiation > 0f) {
@@ -371,7 +353,6 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasSpeed = envMetrics.wind_speed != null && !envMetrics.wind_speed!!.isNaN()
val hasGust = envMetrics.wind_gust != null && !envMetrics.wind_gust!!.isNaN()
@@ -386,7 +367,6 @@ private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -414,7 +394,6 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, hasGust: Boolean, hasLull: Boolean) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (hasGust) {
@@ -435,7 +414,6 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics,
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val has1h = envMetrics.rainfall_1h != null && !envMetrics.rainfall_1h!!.isNaN()
val has24h = envMetrics.rainfall_24h != null && !envMetrics.rainfall_24h!!.isNaN()
@@ -462,34 +440,18 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun EnvironmentMetricsCard(
telemetry: Telemetry,
environmentDisplayFahrenheit: Boolean,
isSelected: Boolean,
onClick: () -> Unit,
) {
- Card(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
- border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
- colors =
- CardDefaults.cardColors(
- containerColor =
- if (isSelected) {
- MaterialTheme.colorScheme.primaryContainer
- } else {
- MaterialTheme.colorScheme.surfaceVariant
- },
- ),
- ) {
- Surface(color = Color.Transparent) {
- SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) }
- }
+ SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
+ EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit)
}
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
val time = telemetry.time.toLong() * MS_PER_SEC
@@ -497,7 +459,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
- text = CommonCharts.formatDateTime(time),
+ text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
@@ -521,9 +483,9 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
}
}
-@Suppress("MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
+@PreviewLightDark
+@Suppress("MagicNumber") // Compose preview with fake data
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PreviewEnvironmentMetricsContent() {
val fakeEnvMetrics =
org.meshtastic.proto.EnvironmentMetrics(
@@ -547,7 +509,5 @@ private fun PreviewEnvironmentMetricsContent() {
rainfall_24h = 12.3f,
)
val fakeTelemetry = Telemetry(time = nowSeconds.toInt(), environment_metrics = fakeEnvMetrics)
- MaterialTheme {
- Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) }
- }
+ AppTheme { Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) } }
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt
index f04121bca5..d4f362ca41 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt
@@ -18,22 +18,17 @@
package org.meshtastic.feature.node.metrics
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
-import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
-import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.free_memory
@@ -104,11 +99,10 @@ internal fun HostMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- Column(modifier = modifier) {
- if (data.isEmpty()) return@Column
-
- val modelProducer = remember { CartesianChartModelProducer() }
-
+ MetricChartScaffold(isEmpty = data.isEmpty(), legendData = HOST_METRICS_LEGEND_DATA, modifier = modifier) {
+ modelProducer,
+ chartModifier,
+ ->
val load1Data = remember(data) { data.filter { it.host_metrics?.load1 != null && it.host_metrics!!.load1 > 0 } }
val load5Data = remember(data) { data.filter { it.host_metrics?.load5 != null && it.host_metrics!!.load5 > 0 } }
val load15Data =
@@ -157,7 +151,7 @@ internal fun HostMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
- when (color.copy(alpha = 1f)) {
+ when (color) {
load1Color -> formatString("L1: %.2f", value)
load5Color -> formatString("L5: %.2f", value)
load15Color -> formatString("L15: %.2f", value)
@@ -167,39 +161,33 @@ internal fun HostMetricsChart(
)
val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty()
+ val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null
+ val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null
+ val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null
+ val loadStyles = listOfNotNull(load1Style, load5Style, load15Style)
val loadLayer =
- if (hasLoad) {
- val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null
- val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null
- val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null
- val styles = listOfNotNull(load1Style, load5Style, load15Style)
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(styles),
- verticalAxisPosition = Axis.Position.Vertical.Start,
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = hasLoad,
+ lineProvider = LineCartesianLayer.LineProvider.series(loadStyles),
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
+ )
val memLayer =
- if (memData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)),
- verticalAxisPosition = Axis.Position.Vertical.End,
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = memData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)),
+ verticalAxisPosition = Axis.Position.Vertical.End,
+ rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
+ )
val layers = remember(loadLayer, memLayer) { listOfNotNull(loadLayer, memLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
- modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
+ modifier = chartModifier,
layers = layers,
startAxis =
if (hasLoad) {
@@ -226,7 +214,5 @@ internal fun HostMetricsChart(
vicoScrollState = vicoScrollState,
)
}
-
- Legend(legendData = HOST_METRICS_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt
index 4a928b98a3..6532938358 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt
@@ -14,9 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
+
package org.meshtastic.feature.node.metrics
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@@ -27,6 +31,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
@@ -38,6 +43,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -49,7 +55,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
/** Shared metric log/list UI components used by TracerouteLog, NeighborInfoLog, HostMetricsLog, and PositionLog. */
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
@@ -99,3 +104,45 @@ fun DeleteItem(onClick: () -> Unit) {
},
)
}
+
+/**
+ * A selectable [Card] for metric log items. Provides consistent selection styling (primary border + primaryContainer
+ * background) and text selection support across all metric screens.
+ */
+@Composable
+fun SelectableMetricCard(
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
+ border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ },
+ ),
+ ) {
+ SelectionContainer { content() }
+ }
+}
+
+/** A compact row displaying a colored [MetricIndicator] dot/line followed by a text value. */
+@Composable
+fun MetricValueRow(color: Color, text: String, modifier: Modifier = Modifier) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
+ MetricIndicator(color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = text,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 93bfb52124..51ef4ef8c0 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -47,7 +47,9 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
+import org.meshtastic.core.model.util.GeoConstants
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
@@ -61,7 +63,6 @@ import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
-import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
@@ -333,12 +334,12 @@ open class MetricsViewModel(
.toLocalDateTime(TimeZone.currentSystemDefault())
val rxDateTime = "\"${localDateTime.date}\",\"${localDateTime.time}\""
- val latitude = (position.latitude_i ?: 0) * 1e-7
- val longitude = (position.longitude_i ?: 0) * 1e-7
+ val latitude = (position.latitude_i ?: 0) * GeoConstants.DEG_D
+ val longitude = (position.longitude_i ?: 0) * GeoConstants.DEG_D
val altitude = position.altitude
val satsInView = position.sats_in_view
val speed = position.ground_speed
- val heading = formatString("%.2f", (position.ground_track ?: 0) * 1e-5)
+ val heading = formatString("%.2f", (position.ground_track ?: 0) * GeoConstants.HEADING_DEG)
sink.writeUtf8(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n",

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt
index 595167a7ed..cad2b63b10 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt
@@ -14,10 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
+
package org.meshtastic.feature.node.metrics
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -46,7 +44,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
-import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
@@ -57,12 +54,19 @@ import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ble_devices
import org.meshtastic.core.resources.no_pax_metrics_logs
import org.meshtastic.core.resources.pax
+import org.meshtastic.core.resources.pax_ble_format
+import org.meshtastic.core.resources.pax_ble_marker
import org.meshtastic.core.resources.pax_metrics_log
+import org.meshtastic.core.resources.pax_total_format
+import org.meshtastic.core.resources.pax_total_marker
+import org.meshtastic.core.resources.pax_wifi_format
+import org.meshtastic.core.resources.pax_wifi_marker
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.IconInfo
@@ -80,14 +84,13 @@ private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
private val LEGEND_DATA =
listOf(
- LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
- LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
- LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
+ LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
+ LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
+ LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
)
@Suppress("LongMethod")
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PaxMetricsChart(
modifier: Modifier = Modifier,
totalSeries: List<Pair<Int, Int>>,
@@ -97,10 +100,10 @@ private fun PaxMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- Column(modifier = modifier) {
- if (totalSeries.isEmpty()) return@Column
-
- val modelProducer = remember { CartesianChartModelProducer() }
+ MetricChartScaffold(isEmpty = totalSeries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) {
+ modelProducer,
+ chartModifier,
+ ->
val paxColor = PaxSeries.PAX.color
val bleColor = PaxSeries.BLE.color
val wifiColor = PaxSeries.WIFI.color
@@ -116,22 +119,26 @@ private fun PaxMetricsChart(
}
val axisLabel = ChartStyling.rememberAxisLabel()
+ val bleMarkerTemplate = stringResource(Res.string.pax_ble_marker)
+ val wifiMarkerTemplate = stringResource(Res.string.pax_wifi_marker)
+ val paxMarkerTemplate = stringResource(Res.string.pax_total_marker)
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
- when (color.copy(alpha = 1f)) {
- bleColor -> formatString("BLE: %.0f", value)
- wifiColor -> formatString("WiFi: %.0f", value)
- paxColor -> formatString("PAX: %.0f", value)
- else -> formatString("%.0f", value)
+ val formatted = formatString("%.0f", value)
+ when (color) {
+ bleColor -> bleMarkerTemplate.replace("%1\$s", formatted)
+ wifiColor -> wifiMarkerTemplate.replace("%1\$s", formatted)
+ paxColor -> paxMarkerTemplate.replace("%1\$s", formatted)
+ else -> formatted
}
},
)
GenericMetricChart(
modelProducer = modelProducer,
- modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
+ modifier = chartModifier,
layers =
listOf(
rememberLineCartesianLayer(
@@ -151,8 +158,6 @@ private fun PaxMetricsChart(
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
-
- Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 4.dp))
}
}
@@ -169,7 +174,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
remember(paxMetrics) {
paxMetrics
.map {
- val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt()
+ val t = (it.first.received_date / MS_PER_SEC).toInt()
Triple(t, it.second.ble, it.second.wifi)
}
.sortedBy { it.first }
@@ -184,7 +189,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
titleRes = Res.string.pax_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = paxMetrics,
- timeProvider = { (it.first.received_date / CommonCharts.MS_PER_SEC).toDouble() },
+ timeProvider = { (it.first.received_date / MS_PER_SEC).toDouble() },
onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) },
controlPart = {
TimeFrameSelector(
@@ -224,8 +229,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
PaxMetricsItem(
log = log,
pax = pax,
- isSelected = (log.received_date / CommonCharts.MS_PER_SEC).toDouble() == selectedX,
- onClick = { onCardClick((log.received_date / CommonCharts.MS_PER_SEC).toDouble()) },
+ isSelected = (log.received_date / MS_PER_SEC).toDouble() == selectedX,
+ onClick = { onCardClick((log.received_date / MS_PER_SEC).toDouble()) },
)
}
}
@@ -250,21 +255,8 @@ fun PaxcountInfo(
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClick: () -> Unit) {
- Card(
- modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
- border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
- colors =
- CardDefaults.cardColors(
- containerColor =
- if (isSelected) {
- MaterialTheme.colorScheme.primaryContainer
- } else {
- MaterialTheme.colorScheme.surfaceVariant
- },
- ),
- ) {
+ SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = DateFormatter.formatDateTime(log.received_date),
@@ -278,17 +270,20 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClic
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
- MetricIndicator(PaxSeries.PAX.color)
- Spacer(Modifier.width(4.dp))
- Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge)
+ MetricValueRow(
+ color = PaxSeries.PAX.color,
+ text = stringResource(Res.string.pax_total_format, pax.ble + pax.wifi),
+ )
Spacer(Modifier.width(8.dp))
- MetricIndicator(PaxSeries.BLE.color)
- Spacer(Modifier.width(4.dp))
- Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge)
+ MetricValueRow(
+ color = PaxSeries.BLE.color,
+ text = stringResource(Res.string.pax_ble_format, pax.ble),
+ )
Spacer(Modifier.width(8.dp))
- MetricIndicator(PaxSeries.WIFI.color)
- Spacer(Modifier.width(4.dp))
- Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge)
+ MetricValueRow(
+ color = PaxSeries.WIFI.color,
+ text = stringResource(Res.string.pax_wifi_format, pax.wifi),
+ )
}
Text(

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt
index 2a79f2fb1d..62ab7a0d4b 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt
@@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.model.util.GeoConstants.DEG_D
+import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
@@ -79,9 +81,6 @@ fun PositionLogHeader(compactWidth: Boolean) {
}
}
-const val DEG_D = 1e-7
-const val HEADING_DEG = 1e-5
-
@Composable
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
Row(

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
index a67d5d7dd5..cb7d147d20 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
@@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -44,12 +45,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clear
+import org.meshtastic.core.resources.collapse_chart
+import org.meshtastic.core.resources.expand_chart
+import org.meshtastic.core.resources.logs
+import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.icon.BarChart
import org.meshtastic.core.ui.icon.Delete
+import org.meshtastic.core.ui.icon.List
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
+import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
@Composable
private fun ActionButtons(
@@ -92,16 +100,32 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
org.meshtastic.core.ui.util.rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri) }
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
+ var isMapExpanded by remember { mutableStateOf(false) }
+
+ val trackMap = LocalNodeTrackMapProvider.current
+ val destNum = state.node?.num ?: 0
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
+ subtitle =
+ stringResource(Res.string.position_log) +
+ " (${state.positionLogs.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
+ IconButton(onClick = { isMapExpanded = !isMapExpanded }) {
+ Icon(
+ imageVector = if (isMapExpanded) MeshtasticIcons.List else MeshtasticIcons.BarChart,
+ contentDescription =
+ stringResource(
+ if (isMapExpanded) Res.string.collapse_chart else Res.string.expand_chart,
+ ),
+ )
+ }
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestPosition() }) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
@@ -112,30 +136,38 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
)
},
) { innerPadding ->
- BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
- val compactWidth = maxWidth < 600.dp
- Column {
- val textStyle =
- if (compactWidth) {
- MaterialTheme.typography.bodySmall
- } else {
- LocalTextStyle.current
- }
- CompositionLocalProvider(LocalTextStyle provides textStyle) {
- PositionLogHeader(compactWidth)
- PositionList(compactWidth, state.positionLogs, state.displayUnits)
- }
+ Column(modifier = Modifier.padding(innerPadding)) {
+ AdaptiveMetricLayout(
+ isChartExpanded = isMapExpanded,
+ chartPart = { modifier -> trackMap(destNum, state.positionLogs, modifier) },
+ listPart = { modifier ->
+ BoxWithConstraints(modifier = modifier) {
+ val compactWidth = maxWidth < 600.dp
+ Column {
+ val textStyle =
+ if (compactWidth) {
+ MaterialTheme.typography.bodySmall
+ } else {
+ LocalTextStyle.current
+ }
+ CompositionLocalProvider(LocalTextStyle provides textStyle) {
+ PositionLogHeader(compactWidth)
+ PositionList(compactWidth, state.positionLogs, state.displayUnits)
+ }
- ActionButtons(
- clearButtonEnabled = clearButtonEnabled,
- onClear = {
- clearButtonEnabled = false
- viewModel.clearPosition()
- },
- saveButtonEnabled = state.hasPositionLogs(),
- onSave = { exportPositionLauncher("position.csv", "text/csv") },
- )
- }
+ ActionButtons(
+ clearButtonEnabled = clearButtonEnabled,
+ onClear = {
+ clearButtonEnabled = false
+ viewModel.clearPosition()
+ },
+ saveButtonEnabled = state.hasPositionLogs(),
+ onSave = { exportPositionLauncher("position.csv", "text/csv") },
+ )
+ }
+ }
+ },
+ )
}
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
index 234ba269ac..ebfae84078 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
@@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -29,17 +28,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -47,7 +41,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
@@ -57,14 +50,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
-import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
-import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
import org.meshtastic.core.resources.channel_2
@@ -79,7 +72,6 @@ import org.meshtastic.core.resources.power_metrics_log
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
-import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
private enum class PowerMetric(val color: Color) {
@@ -100,18 +92,8 @@ private enum class PowerChannel(val strRes: StringResource) {
private val LEGEND_DATA =
listOf(
- LegendData(
- nameRes = Res.string.current,
- color = PowerMetric.CURRENT.color,
- isLine = true,
- environmentMetric = null,
- ),
- LegendData(
- nameRes = Res.string.voltage,
- color = PowerMetric.VOLTAGE.color,
- isLine = true,
- environmentMetric = null,
- ),
+ LegendData(nameRes = Res.string.current, color = PowerMetric.CURRENT.color, isLine = true),
+ LegendData(nameRes = Res.string.voltage, color = PowerMetric.VOLTAGE.color, isLine = true),
)
@Suppress("LongMethod")
@@ -187,7 +169,6 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod")
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
@@ -196,17 +177,19 @@ private fun PowerMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- Column(modifier = modifier) {
- if (telemetries.isEmpty()) return@Column
-
- val modelProducer = remember { CartesianChartModelProducer() }
+ MetricChartScaffold(
+ isEmpty = telemetries.isEmpty(),
+ legendData = LEGEND_DATA,
+ modifier = modifier,
+ key = selectedChannel,
+ ) { modelProducer, chartModifier ->
val currentColor = PowerMetric.CURRENT.color
val voltageColor = PowerMetric.VOLTAGE.color
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
- when (color.copy(alpha = 1f)) {
+ when (color) {
currentColor -> formatString("Current: %.0f mA", value)
voltageColor -> formatString("Voltage: %.1f V", value)
else -> formatString("%.1f", value)
@@ -223,7 +206,7 @@ private fun PowerMetricsChart(
telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() }
}
- LaunchedEffect(currentData, voltageData) {
+ LaunchedEffect(selectedChannel, currentData, voltageData) {
modelProducer.runTransaction {
if (currentData.isNotEmpty()) {
lineSeries {
@@ -245,32 +228,25 @@ private fun PowerMetricsChart(
}
val currentLayer =
- if (currentData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)),
- verticalAxisPosition = Axis.Position.Vertical.Start,
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = currentData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)),
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ )
val voltageLayer =
- if (voltageData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider =
- LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)),
- verticalAxisPosition = Axis.Position.Vertical.End,
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = voltageData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)),
+ verticalAxisPosition = Axis.Position.Vertical.End,
+ )
val layers = remember(currentLayer, voltageLayer) { listOfNotNull(currentLayer, voltageLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
- modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
+ modifier = chartModifier,
layers = layers,
startAxis =
if (currentData.isNotEmpty()) {
@@ -297,50 +273,31 @@ private fun PowerMetricsChart(
vicoScrollState = vicoScrollState,
)
}
-
- Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}
@Composable
@Suppress("CyclomaticComplexMethod", "LongMethod")
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val time = telemetry.time.toLong() * MS_PER_SEC
- Card(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
- border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
- colors =
- CardDefaults.cardColors(
- containerColor =
- if (isSelected) {
- MaterialTheme.colorScheme.primaryContainer
- } else {
- MaterialTheme.colorScheme.surfaceVariant
- },
- ),
- ) {
- Surface {
- SelectionContainer {
- Row(modifier = Modifier.fillMaxWidth()) {
- Column(modifier = Modifier.padding(12.dp)) {
- /* Time */
- Row {
- Text(
- text = CommonCharts.formatDateTime(time),
- style = MaterialTheme.typography.titleMediumEmphasized,
- fontWeight = FontWeight.Bold,
- )
- }
+ SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ /* Time */
+ Row {
+ Text(
+ text = DateFormatter.formatDateTime(time),
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ fontWeight = FontWeight.Bold,
+ )
+ }
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(8.dp))
- val pm = telemetry.power_metrics
- if (pm != null) {
- PowerChannelsRow1(pm)
- PowerChannelsExtraRows(pm)
- }
- }
+ val pm = telemetry.power_metrics
+ if (pm != null) {
+ PowerChannelsRow1(pm)
+ PowerChannelsExtraRows(pm)
}
}
}
@@ -348,7 +305,6 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick:
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
if (pm.ch1_current != null || pm.ch1_voltage != null) {
@@ -365,7 +321,6 @@ private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) {
@Composable
@Suppress("CyclomaticComplexMethod")
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) {
val hasCh456 =
hasChannelData(pm.ch4_voltage, pm.ch4_current) ||
@@ -403,7 +358,6 @@ private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) {
private fun hasChannelData(voltage: Float?, current: Float?): Boolean = voltage != null || current != null
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current: Float) {
Column {
Text(
@@ -411,30 +365,13 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
- Row(verticalAlignment = Alignment.CenterVertically) {
- MetricIndicator(PowerMetric.VOLTAGE.color)
- Spacer(Modifier.width(4.dp))
- Text(
- text = formatString("%.2fV", voltage),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- MetricIndicator(PowerMetric.CURRENT.color)
- Spacer(Modifier.width(4.dp))
- Text(
- text = formatString("%.1fmA", current),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
- }
+ MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage))
+ MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current))
}
}
/** Retrieves the appropriate voltage depending on `channelSelected`. */
@Suppress("CyclomaticComplexMethod")
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.power_metrics?.ch1_voltage ?: Float.NaN
PowerChannel.TWO -> telemetry.power_metrics?.ch2_voltage ?: Float.NaN
@@ -448,7 +385,6 @@ private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry)
/** Retrieves the appropriate current depending on `channelSelected`. */
@Suppress("CyclomaticComplexMethod")
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.power_metrics?.ch1_current ?: Float.NaN
PowerChannel.TWO -> telemetry.power_metrics?.ch2_current ?: Float.NaN

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
index 4105eb749e..376b55289e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
@@ -14,10 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
+
package org.meshtastic.feature.node.metrics
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,12 +31,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -51,12 +47,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
-import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
-import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.rssi_definition
@@ -66,7 +62,6 @@ import org.meshtastic.core.resources.snr_definition
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Green
-import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshPacket
private enum class SignalMetric(val color: Color) {
@@ -76,8 +71,8 @@ private enum class SignalMetric(val color: Color) {
private val LEGEND_DATA =
listOf(
- LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color, environmentMetric = null),
- LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color, environmentMetric = null),
+ LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color),
+ LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color),
)
@Suppress("LongMethod")
@@ -134,7 +129,6 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SignalMetricsChart(
modifier: Modifier = Modifier,
meshPackets: List<MeshPacket>,
@@ -142,10 +136,10 @@ private fun SignalMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- Column(modifier = modifier) {
- if (meshPackets.isEmpty()) return@Column
-
- val modelProducer = remember { CartesianChartModelProducer() }
+ MetricChartScaffold(isEmpty = meshPackets.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) {
+ modelProducer,
+ chartModifier,
+ ->
val rssiColor = SignalMetric.RSSI.color
val snrColor = SignalMetric.SNR.color
@@ -168,7 +162,7 @@ private fun SignalMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
- if (color.copy(alpha = 1f) == rssiColor) {
+ if (color == rssiColor) {
formatString("RSSI: %.0f dBm", value)
} else {
formatString("SNR: %.1f dB", value)
@@ -177,31 +171,25 @@ private fun SignalMetricsChart(
)
val rssiLayer =
- if (rssiData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)),
- verticalAxisPosition = Axis.Position.Vertical.Start,
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = rssiData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)),
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ )
val snrLayer =
- if (snrData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)),
- verticalAxisPosition = Axis.Position.Vertical.End,
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = snrData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)),
+ verticalAxisPosition = Axis.Position.Vertical.End,
+ )
val layers = remember(rssiLayer, snrLayer) { listOfNotNull(rssiLayer, snrLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
- modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
+ modifier = chartModifier,
layers = layers,
startAxis =
if (rssiData.isNotEmpty()) {
@@ -228,70 +216,47 @@ private fun SignalMetricsChart(
vicoScrollState = vicoScrollState,
)
}
-
- Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}
@Composable
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
val time = meshPacket.rx_time.toLong() * MS_PER_SEC
- Card(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
- border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
- colors =
- CardDefaults.cardColors(
- containerColor =
- if (isSelected) {
- MaterialTheme.colorScheme.primaryContainer
- } else {
- MaterialTheme.colorScheme.surfaceVariant
- },
- ),
- ) {
- Surface(color = Color.Transparent) {
- SelectionContainer {
- Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- /* Data */
- Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
- Column(modifier = Modifier.padding(12.dp)) {
- /* Time */
- Row(horizontalArrangement = Arrangement.SpaceBetween) {
- Text(
- text = CommonCharts.formatDateTime(time),
- style = MaterialTheme.typography.titleMediumEmphasized,
- fontWeight = FontWeight.Bold,
- )
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- /* SNR and RSSI */
- Row(verticalAlignment = Alignment.CenterVertically) {
- MetricIndicator(SignalMetric.RSSI.color)
- Spacer(Modifier.width(4.dp))
- Text(
- text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
- style = MaterialTheme.typography.labelLarge,
- )
- Spacer(Modifier.width(12.dp))
- MetricIndicator(SignalMetric.SNR.color)
- Spacer(Modifier.width(4.dp))
- Text(
- text = formatString("%.1f dB", meshPacket.rx_snr),
- style = MaterialTheme.typography.labelLarge,
- )
- }
- }
+ SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ /* Data */
+ Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ /* Time */
+ Row(horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ text = DateFormatter.formatDateTime(time),
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ fontWeight = FontWeight.Bold,
+ )
}
- /* Signal Indicator */
- Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
- LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
+ Spacer(modifier = Modifier.height(8.dp))
+
+ /* SNR and RSSI */
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricValueRow(
+ color = SignalMetric.RSSI.color,
+ text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
+ )
+ Spacer(Modifier.width(12.dp))
+ MetricValueRow(
+ color = SignalMetric.SNR.color,
+ text = formatString("%.1f dB", meshPacket.rx_snr),
+ )
}
}
}
+
+ /* Signal Indicator */
+ Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
+ LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
+ }
}
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
index 76ac085028..ce63002057 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
@@ -18,22 +18,17 @@
package org.meshtastic.feature.node.metrics
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
-import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
-import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.fullRouteDiscovery
@@ -151,11 +146,10 @@ internal fun TracerouteMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- Column(modifier = modifier) {
- if (points.isEmpty()) return@Column
-
- val modelProducer = remember { CartesianChartModelProducer() }
-
+ MetricChartScaffold(isEmpty = points.isEmpty(), legendData = TRACEROUTE_LEGEND_DATA, modifier = modifier) {
+ modelProducer,
+ chartModifier,
+ ->
val forwardData = remember(points) { points.filter { it.forwardHops != null } }
val returnData = remember(points) { points.filter { it.returnHops != null } }
val rttData = remember(points) { points.filter { it.roundTripSeconds != null } }
@@ -184,7 +178,7 @@ internal fun TracerouteMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
- when (color.copy(alpha = 1f)) {
+ when (color) {
forwardColor -> formatString("Fwd: %.0f hops", value)
returnColor -> formatString("Ret: %.0f hops", value)
else -> formatString("RTT: %.1f s", value)
@@ -193,36 +187,27 @@ internal fun TracerouteMetricsChart(
)
val forwardLayer =
- if (forwardData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)),
- verticalAxisPosition = Axis.Position.Vertical.Start,
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = forwardData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)),
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
+ )
val returnLayer =
- if (returnData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)),
- verticalAxisPosition = Axis.Position.Vertical.Start,
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = returnData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)),
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
+ )
val rttLayer =
- if (rttData.isNotEmpty()) {
- rememberLineCartesianLayer(
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)),
- verticalAxisPosition = Axis.Position.Vertical.End,
- )
- } else {
- null
- }
+ rememberConditionalLayer(
+ hasData = rttData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)),
+ verticalAxisPosition = Axis.Position.Vertical.End,
+ )
val layers =
remember(forwardLayer, returnLayer, rttLayer) { listOfNotNull(forwardLayer, returnLayer, rttLayer) }
@@ -230,7 +215,7 @@ internal fun TracerouteMetricsChart(
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
- modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
+ modifier = chartModifier,
layers = layers,
startAxis =
if (forwardData.isNotEmpty() || returnData.isNotEmpty()) {
@@ -257,7 +242,5 @@ internal fun TracerouteMetricsChart(
vicoScrollState = vicoScrollState,
)
}
-
- Legend(legendData = TRACEROUTE_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
index 6fa914b2a6..bf5846e9f1 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
@@ -58,6 +58,7 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
@@ -83,7 +84,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateTraceroute
-import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.proto.RouteDiscovery

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt
index 3bd4a4a5ba..7f8578bfa9 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt
@@ -27,7 +27,6 @@ import org.meshtastic.core.resources.host_metrics_log
import org.meshtastic.core.resources.ic_charging_station
import org.meshtastic.core.resources.ic_groups
import org.meshtastic.core.resources.ic_location_on
-import org.meshtastic.core.resources.ic_map
import org.meshtastic.core.resources.ic_memory
import org.meshtastic.core.resources.ic_people
import org.meshtastic.core.resources.ic_power
@@ -35,7 +34,6 @@ import org.meshtastic.core.resources.ic_route
import org.meshtastic.core.resources.ic_signal_cellular_alt
import org.meshtastic.core.resources.ic_thermostat
import org.meshtastic.core.resources.neighbor_info
-import org.meshtastic.core.resources.node_map
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.power_metrics_log
@@ -44,7 +42,6 @@ import org.meshtastic.core.resources.traceroute_log
enum class LogsType(val titleRes: StringResource, val icon: DrawableResource, val routeFactory: (Int) -> Route) {
DEVICE(Res.string.device_metrics_log, Res.drawable.ic_charging_station, { NodeDetailRoute.DeviceMetrics(it) }),
- NODE_MAP(Res.string.node_map, Res.drawable.ic_map, { NodeDetailRoute.NodeMap(it) }),
POSITIONS(Res.string.position_log, Res.drawable.ic_location_on, { NodeDetailRoute.PositionLog(it) }),
ENVIRONMENT(Res.string.env_metrics_log, Res.drawable.ic_thermostat, { NodeDetailRoute.EnvironmentMetrics(it) }),
SIGNAL(Res.string.signal_quality, Res.drawable.ic_signal_cellular_alt, { NodeDetailRoute.SignalMetrics(it) }),

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index b80d7cba5d..883ffa6b6e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -122,11 +122,6 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
)
}
- entry<NodeDetailRoute.NodeMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
- val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current
- mapScreen(args.destNum) { backStack.removeLastOrNull() }
- }
-
entry<NodeDetailRoute.TracerouteLog>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum)

diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt
new file mode 100644
index 0000000000..98f7d3bbe9
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.metrics
+
+import okio.ByteString.Companion.decodeBase64
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.core.model.MeshLog
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.FromRadio
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import org.meshtastic.proto.Paxcount as ProtoPaxcount
+
+/**
+ * Tests for `MetricsViewModel.decodePaxFromLog()`.
+ *
+ * Uses a minimal testable subclass to access the protected function without wiring the full ViewModel dependency graph.
+ */
+class DecodePaxFromLogTest {
+
+ /**
+ * Minimal subclass that exposes `decodePaxFromLog` without requiring all ViewModel dependencies. `MetricsViewModel`
+ * is open, so we override with no-op constructor arguments are not needed — we only call the self-contained
+ * `decodePaxFromLog` method.
+ */
+ private val decoder =
+ object {
+ /** Delegates to MetricsViewModel logic extracted into a standalone helper for testing. */
+ fun decode(log: MeshLog): ProtoPaxcount? = decodePaxFromLogStandalone(log)
+ }
+
+ // ---- Binary proto path ----
+
+ @Test
+ fun binaryProto_validPaxcount_decoded() {
+ val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 3600)
+ val payload = ProtoPaxcount.ADAPTER.encode(pax)
+ val log = meshLogWithPacket(payload, wantResponse = false)
+
+ val result = decoder.decode(log)
+ assertNotNull(result)
+ assertEquals(10, result.wifi)
+ assertEquals(5, result.ble)
+ assertEquals(3600, result.uptime)
+ }
+
+ @Test
+ fun binaryProto_wantResponse_returnsNull() {
+ val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100)
+ val payload = ProtoPaxcount.ADAPTER.encode(pax)
+ val log = meshLogWithPacket(payload, wantResponse = true)
+
+ assertNull(decoder.decode(log))
+ }
+
+ @Test
+ fun binaryProto_allZeroValues_returnsNull() {
+ val pax = ProtoPaxcount(wifi = 0, ble = 0, uptime = 0)
+ val payload = ProtoPaxcount.ADAPTER.encode(pax)
+ val log = meshLogWithPacket(payload, wantResponse = false)
+
+ assertNull(decoder.decode(log))
+ }
+
+ @Test
+ fun binaryProto_wrongPortNum_returnsNull() {
+ val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100)
+ val payload = ProtoPaxcount.ADAPTER.encode(pax)
+ val log = meshLogWithPacket(payload, wantResponse = false, portNum = PortNum.POSITION_APP)
+
+ assertNull(decoder.decode(log))
+ }
+
+ // ---- Base64 fallback path ----
+
+ @Test
+ fun base64Fallback_validPayload_decoded() {
+ val pax = ProtoPaxcount(wifi = 7, ble = 3, uptime = 500)
+ val bytes = ProtoPaxcount.ADAPTER.encode(pax)
+ val base64 = okio.ByteString.of(*bytes).base64()
+ val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = base64)
+
+ val result = decoder.decode(log)
+ assertNotNull(result)
+ assertEquals(7, result.wifi)
+ assertEquals(3, result.ble)
+ }
+
+ // ---- Hex fallback path ----
+ // Note: The hex path (`else if`) in the original code is unreachable for pure hex strings
+ // because hex chars [0-9a-fA-F] are a strict subset of base64 chars [A-Za-z0-9+/=].
+ // The base64 `if` branch always matches first. The hex fallback would only trigger for
+ // strings that fail the base64 regex but pass the hex regex — which is impossible given
+ // the charsets. This is documented here as a known design characteristic of decodePaxFromLog().
+
+ // ---- Error handling ----
+
+ @Test
+ fun invalidRawMessage_returnsNull() {
+ val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "not-valid-anything!@#")
+
+ assertNull(decoder.decode(log))
+ }
+
+ @Test
+ fun emptyLog_returnsNull() {
+ val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "")
+
+ assertNull(decoder.decode(log))
+ }
+
+ // ---- Helpers ----
+
+ private fun meshLogWithPacket(
+ payload: ByteArray,
+ wantResponse: Boolean,
+ portNum: PortNum = PortNum.PAXCOUNTER_APP,
+ ): MeshLog {
+ val data = Data(portnum = portNum, payload = payload.toByteString(), want_response = wantResponse)
+ val packet = MeshPacket(decoded = data)
+ val fromRadio = FromRadio(packet = packet)
+ return MeshLog(
+ uuid = "test",
+ message_type = "packet",
+ received_date = nowSeconds * 1000,
+ raw_message = "",
+ fromRadio = fromRadio,
+ )
+ }
+}
+
+/**
+ * Standalone reimplementation of `MetricsViewModel.decodePaxFromLog()` for testing.
+ *
+ * This avoids needing to instantiate the full ViewModel with all its dependencies. The logic is identical to the
+ * ViewModel method.
+ */
+@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
+private fun decodePaxFromLogStandalone(log: MeshLog): ProtoPaxcount? {
+ try {
+ val packet = log.fromRadio.packet
+ val decoded = packet?.decoded
+ if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
+ if (decoded.want_response == true) return null
+ val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
+ if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) return pax
+ }
+ } catch (e: Exception) {
+ // Swallow, fall through to alternative parsing
+ }
+ try {
+ val base64 = log.raw_message.trim()
+ if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) {
+ val bytes = base64.okioDecodeBase64()
+ return ProtoPaxcount.ADAPTER.decode(bytes)
+ } else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
+ val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ return ProtoPaxcount.ADAPTER.decode(bytes)
+ }
+ } catch (e: Exception) {
+ // Swallow
+ }
+ return null
+}
+
+private fun String.okioDecodeBase64(): ByteArray = this.decodeBase64()?.toByteArray() ?: ByteArray(0)

diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt
new file mode 100644
index 0000000000..10cdb42d5e
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt
@@ -0,0 +1,275 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.metrics
+
+import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.proto.EnvironmentMetrics
+import org.meshtastic.proto.Telemetry
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@Suppress("MagicNumber")
+class EnvironmentMetricsForGraphingTest {
+
+ private val now = nowSeconds.toInt()
+
+ private fun telemetry(time: Int = now, env: EnvironmentMetrics) = Telemetry(time = time, environment_metrics = env)
+
+ // ---- Empty input ----
+
+ @Test
+ fun emptyMetrics_returnsDefaultGraphingData() {
+ val state = EnvironmentMetricsState(emptyList())
+ val result = state.environmentMetricsForGraphing()
+
+ assertTrue(result.metrics.isEmpty())
+ assertTrue(result.shouldPlot.none { it })
+ }
+
+ // ---- Fahrenheit conversion ----
+
+ @Test
+ fun useFahrenheit_convertsTemperatureMinMax() {
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(temperature = 0f)),
+ telemetry(env = EnvironmentMetrics(temperature = 100f)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true)
+
+ assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
+ // 0C = 32F, 100C = 212F
+ assertEquals(32f, result.rightMinMax.first, 0.01f)
+ assertEquals(212f, result.rightMinMax.second, 0.01f)
+ }
+
+ @Test
+ fun useFahrenheit_convertsSoilTemperature() {
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(soil_temperature = 20f)),
+ telemetry(env = EnvironmentMetrics(soil_temperature = 30f)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true)
+
+ assertTrue(result.shouldPlot[Environment.SOIL_TEMPERATURE.ordinal])
+ // 20C = 68F, 30C = 86F
+ assertEquals(68f, result.rightMinMax.first, 0.01f)
+ assertEquals(86f, result.rightMinMax.second, 0.01f)
+ }
+
+ // ---- Humidity filtering ----
+
+ @Test
+ fun humidity_zeroFilteredOut() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(relative_humidity = 0.0f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertFalse(result.shouldPlot[Environment.HUMIDITY.ordinal])
+ }
+
+ @Test
+ fun humidity_nonZeroIncluded() {
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(relative_humidity = 45f)),
+ telemetry(env = EnvironmentMetrics(relative_humidity = 65f)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal])
+ assertEquals(45f, result.rightMinMax.first, 0.01f)
+ assertEquals(65f, result.rightMinMax.second, 0.01f)
+ }
+
+ // ---- IAQ sentinel filtering ----
+
+ @Test
+ fun iaq_intMinValueFilteredOut() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(iaq = Int.MIN_VALUE)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertFalse(result.shouldPlot[Environment.IAQ.ordinal])
+ }
+
+ @Test
+ fun iaq_validValueIncluded() {
+ val metrics =
+ listOf(telemetry(env = EnvironmentMetrics(iaq = 50)), telemetry(env = EnvironmentMetrics(iaq = 150)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.IAQ.ordinal])
+ assertEquals(50f, result.rightMinMax.first, 0.01f)
+ assertEquals(150f, result.rightMinMax.second, 0.01f)
+ }
+
+ // ---- Soil moisture sentinel filtering ----
+
+ @Test
+ fun soilMoisture_intMinValueFilteredOut() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(soil_moisture = Int.MIN_VALUE)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertFalse(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal])
+ }
+
+ @Test
+ fun soilMoisture_validValueIncluded() {
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(soil_moisture = 30)),
+ telemetry(env = EnvironmentMetrics(soil_moisture = 70)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal])
+ }
+
+ // ---- Barometric pressure (left axis) ----
+
+ @Test
+ fun barometricPressure_onLeftAxis() {
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f)),
+ telemetry(env = EnvironmentMetrics(barometric_pressure = 1020.50f)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal])
+ assertEquals(1013.25f, result.leftMinMax.first, 0.01f)
+ assertEquals(1020.50f, result.leftMinMax.second, 0.01f)
+ }
+
+ @Test
+ fun barometricPressure_doesNotAffectRightAxis() {
+ // Only pressure, no other metrics
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ // rightMinMax should be 0/1 defaults since no right-axis metrics
+ assertEquals(0f, result.rightMinMax.first, 0.01f)
+ assertEquals(1f, result.rightMinMax.second, 0.01f)
+ }
+
+ // ---- Lux, UV lux, wind speed, radiation ----
+
+ @Test
+ fun lux_plotted() {
+ val metrics =
+ listOf(telemetry(env = EnvironmentMetrics(lux = 500f)), telemetry(env = EnvironmentMetrics(lux = 1200f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.LUX.ordinal])
+ assertEquals(500f, result.rightMinMax.first, 0.01f)
+ assertEquals(1200f, result.rightMinMax.second, 0.01f)
+ }
+
+ @Test
+ fun uvLux_plotted() {
+ val metrics =
+ listOf(telemetry(env = EnvironmentMetrics(uv_lux = 2f)), telemetry(env = EnvironmentMetrics(uv_lux = 8f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.UV_LUX.ordinal])
+ }
+
+ @Test
+ fun windSpeed_plotted() {
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(wind_speed = 5f)),
+ telemetry(env = EnvironmentMetrics(wind_speed = 25f)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.WIND_SPEED.ordinal])
+ }
+
+ @Test
+ fun radiation_positiveValuesOnly() {
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(radiation = 0f)),
+ telemetry(env = EnvironmentMetrics(radiation = 0.15f)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.RADIATION.ordinal])
+ // 0f is filtered out (radiation > 0f only), so min should be 0.15
+ assertEquals(0.15f, result.rightMinMax.first, 0.01f)
+ assertEquals(0.15f, result.rightMinMax.second, 0.01f)
+ }
+
+ // ---- NaN filtering ----
+
+ @Test
+ fun nanTemperature_filteredOut() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(temperature = Float.NaN)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertFalse(result.shouldPlot[Environment.TEMPERATURE.ordinal])
+ }
+
+ @Test
+ fun nanPressure_filteredOut() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = Float.NaN)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertFalse(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal])
+ assertEquals(0f, result.leftMinMax.first, 0.01f)
+ assertEquals(0f, result.leftMinMax.second, 0.01f)
+ }
+
+ // ---- Multiple metrics combined ----
+
+ @Test
+ fun multipleMetrics_rightAxisMinMaxSpansAll() {
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(temperature = 10f, relative_humidity = 80f)),
+ telemetry(env = EnvironmentMetrics(temperature = 30f, relative_humidity = 40f)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
+ assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal])
+ // right min/max should span both: min(10, 40) = 10, max(30, 80) = 80
+ assertEquals(10f, result.rightMinMax.first, 0.01f)
+ assertEquals(80f, result.rightMinMax.second, 0.01f)
+ }
+
+ // ---- Gas resistance ----
+
+ // ---- Gas resistance (not currently graphed by environmentMetricsForGraphing) ----
+
+ @Test
+ fun gasResistance_notPlottedByGraphingFunction() {
+ // Note: GAS_RESISTANCE is defined in the Environment enum but environmentMetricsForGraphing()
+ // does not have explicit handling for it. This test documents that current behavior.
+ val metrics =
+ listOf(
+ telemetry(env = EnvironmentMetrics(gas_resistance = 100f)),
+ telemetry(env = EnvironmentMetrics(gas_resistance = 500f)),
+ )
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertFalse(result.shouldPlot[Environment.GAS_RESISTANCE.ordinal])
+ }
+}

diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt
new file mode 100644
index 0000000000..d458409705
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.metrics
+
+import org.meshtastic.proto.HardwareModel
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class HardwareModelSafeNumberTest {
+
+ @Test
+ fun knownModel_returnsValue() {
+ assertEquals(HardwareModel.TBEAM.value, HardwareModel.TBEAM.safeNumber())
+ }
+
+ @Test
+ fun unset_returnsZero() {
+ assertEquals(0, HardwareModel.UNSET.safeNumber())
+ }
+
+ @Test
+ fun customFallback_used() {
+ // Known model with custom fallback — should still return real value
+ assertEquals(HardwareModel.HELTEC_V3.value, HardwareModel.HELTEC_V3.safeNumber(fallbackValue = 999))
+ }
+
+ @Test
+ fun defaultFallback_isNegativeOne() {
+ // For known models the fallback is never used, but verify the API default
+ val result = HardwareModel.UNSET.safeNumber()
+ assertEquals(0, result) // UNSET.value is 0, not the fallback
+ }
+}

diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt
new file mode 100644
index 0000000000..87579610d3
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.model
+
+import org.meshtastic.core.common.util.nowSeconds
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@Suppress("MagicNumber")
+class TimeFrameTest {
+
+ // ---- timeThreshold ----
+
+ @Test
+ fun allTime_thresholdIsZero() {
+ assertEquals(0L, TimeFrame.ALL_TIME.timeThreshold(now = 1000000L))
+ }
+
+ @Test
+ fun oneHour_thresholdIsNowMinus3600() {
+ val now = 1000000L
+ assertEquals(now - 3600, TimeFrame.ONE_HOUR.timeThreshold(now = now))
+ }
+
+ @Test
+ fun twentyFourHours_thresholdIsNowMinus86400() {
+ val now = 1000000L
+ assertEquals(now - 86400, TimeFrame.TWENTY_FOUR_HOURS.timeThreshold(now = now))
+ }
+
+ @Test
+ fun sevenDays_thresholdIsNowMinus604800() {
+ val now = 1000000L
+ assertEquals(now - 604800, TimeFrame.SEVEN_DAYS.timeThreshold(now = now))
+ }
+
+ @Test
+ fun twoWeeks_thresholdIsCorrect() {
+ val now = 2000000L
+ assertEquals(now - 1209600, TimeFrame.TWO_WEEKS.timeThreshold(now = now))
+ }
+
+ @Test
+ fun oneMonth_thresholdIsCorrect() {
+ val now = 3000000L
+ assertEquals(now - 2592000, TimeFrame.ONE_MONTH.timeThreshold(now = now))
+ }
+
+ // ---- isAvailable ----
+
+ @Test
+ fun allTime_alwaysAvailable() {
+ assertTrue(TimeFrame.ALL_TIME.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds))
+ }
+
+ @Test
+ fun oneHour_alwaysAvailable() {
+ assertTrue(TimeFrame.ONE_HOUR.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds))
+ }
+
+ @Test
+ fun twentyFourHours_availableWhenDataOlderThan24h() {
+ val now = 1000000L
+ val oldest = now - 90000 // 25 hours ago
+ assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
+ }
+
+ @Test
+ fun twentyFourHours_notAvailableWhenDataYoungerThan24h() {
+ val now = 1000000L
+ val oldest = now - 3600 // 1 hour ago
+ assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
+ }
+
+ @Test
+ fun sevenDays_notAvailableForTwoDayOldData() {
+ val now = 1000000L
+ val oldest = now - (2 * 86400) // 2 days ago
+ assertFalse(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now))
+ }
+
+ @Test
+ fun sevenDays_availableForEightDayOldData() {
+ val now = 1000000L
+ val oldest = now - (8 * 86400) // 8 days ago
+ assertTrue(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now))
+ }
+
+ @Test
+ fun isAvailable_exactBoundary_returnsTrue() {
+ val now = 1000000L
+ // Exactly 24 hours of data range
+ val oldest = now - 86400
+ assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
+ }
+
+ @Test
+ fun isAvailable_justUnderBoundary_returnsFalse() {
+ val now = 1000000L
+ // One second less than 24 hours
+ val oldest = now - 86399
+ assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
+ }
+}

Served by rngit 1.5.0 - Generated in 1.12s